From c1a4323b8c09513889fdf034b7e8faf4bc5b59fa Mon Sep 17 00:00:00 2001 From: Wenbo Jie Date: Wed, 15 Mar 2023 01:21:59 +0000 Subject: [PATCH] Reland "[Files F2] Add actions/reducers for volume/navigation" This is a reland of commit 11e5c1204aca43241640eb9ef8b5981bc1a22df0, the previous failure was related to "update rootType for VolumeEntry", that line is now removed. There's a also a TODO added when creating ScannerFactory: instead of solely depending on rootType, we need to aso make sure only creating CrostniMounter for fake crostini entry. Failed tast tests mentioned in b/272557385 has been tested manually, they all passed locally on my DUT. Original change's description: > [Files F2] Add actions/reducers for volume/navigation > > * Add actions/reducers for: > * volume: volume information (e.g. data from VolumeInfo and > VolumeMetadata) > * folderShortcuts: shortcut information > * uiEntries: fake entries on the UI (e.g. FakeDriveRoot, Trash) > * androidApps: Android app information (for File picker) > * navigation: roots for the navigation tree > * Add an action reducer to read child entries and store the children > in the store. > * Replicate all nesting logic from NavigationListModel in the store. > > Bug: b:228139957 > Change-Id: I33a68e9f3fea5a7a0da757aab7c63fed6d680e25 > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4150614 > Commit-Queue: Wenbo Jie > Reviewed-by: Luciano Pacheco > Cr-Commit-Position: refs/heads/main@{#1114259} Bug: b:228139957 Change-Id: I483bae1e9545d94eb58bf05e78f1a4f53dc09f9d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4337056 Reviewed-by: Luciano Pacheco Cr-Commit-Position: refs/heads/main@{#1117313} --- .../ash/file_manager/file_manager_jstest.cc | 24 + .../file_manager/common/js/entry_utils.ts | 108 +++- .../definitions/file_manager.d.ts | 1 + .../file_manager/externs/ts/state.js | 4 + .../foreground/js/directory_contents.js | 8 +- .../foreground/js/directory_model.js | 2 + ui/file_manager/file_manager/state/actions.ts | 26 +- .../file_manager/state/actions/all_entries.ts | 20 + .../state/actions/android_apps.ts | 31 + .../state/actions/folder_shortcuts.ts | 71 ++ .../file_manager/state/actions/navigation.ts | 47 ++ .../file_manager/state/actions/ui_entries.ts | 51 ++ .../file_manager/state/actions/volumes.ts | 48 ++ .../state/actions_producers/all_entries.ts | 141 ++++ .../actions_producers/all_entries_unittest.ts | 203 ++++++ .../file_manager/state/for_tests.ts | 136 +++- .../state/reducers/all_entries.ts | 502 ++++++++++++++- .../state/reducers/all_entries_unittest.ts | 401 +++++++++++- .../state/reducers/android_apps.ts | 27 + .../state/reducers/android_apps_unittest.ts | 40 ++ .../state/reducers/current_directory.ts | 12 +- .../reducers/current_directory_unittest.ts | 35 +- .../state/reducers/folder_shortcuts.ts | 69 ++ .../reducers/folder_shortcuts_unittest.ts | 116 ++++ .../file_manager/state/reducers/navigation.ts | 238 +++++++ .../state/reducers/navigation_unittest.ts | 609 ++++++++++++++++++ .../file_manager/state/reducers/root.ts | 32 +- .../file_manager/state/reducers/ui_entries.ts | 104 +++ .../state/reducers/ui_entries_unittest.ts | 250 +++++++ .../file_manager/state/reducers/volumes.ts | 173 +++++ .../state/reducers/volumes_unittest.ts | 227 +++++++ ui/file_manager/file_names.gni | 19 + 32 files changed, 3689 insertions(+), 86 deletions(-) create mode 100644 ui/file_manager/file_manager/state/actions/android_apps.ts create mode 100644 ui/file_manager/file_manager/state/actions/folder_shortcuts.ts create mode 100644 ui/file_manager/file_manager/state/actions/navigation.ts create mode 100644 ui/file_manager/file_manager/state/actions/ui_entries.ts create mode 100644 ui/file_manager/file_manager/state/actions/volumes.ts create mode 100644 ui/file_manager/file_manager/state/actions_producers/all_entries.ts create mode 100644 ui/file_manager/file_manager/state/actions_producers/all_entries_unittest.ts create mode 100644 ui/file_manager/file_manager/state/reducers/android_apps.ts create mode 100644 ui/file_manager/file_manager/state/reducers/android_apps_unittest.ts create mode 100644 ui/file_manager/file_manager/state/reducers/folder_shortcuts.ts create mode 100644 ui/file_manager/file_manager/state/reducers/folder_shortcuts_unittest.ts create mode 100644 ui/file_manager/file_manager/state/reducers/navigation.ts create mode 100644 ui/file_manager/file_manager/state/reducers/navigation_unittest.ts create mode 100644 ui/file_manager/file_manager/state/reducers/ui_entries.ts create mode 100644 ui/file_manager/file_manager/state/reducers/ui_entries_unittest.ts create mode 100644 ui/file_manager/file_manager/state/reducers/volumes.ts create mode 100644 ui/file_manager/file_manager/state/reducers/volumes_unittest.ts diff --git a/chrome/browser/ash/file_manager/file_manager_jstest.cc b/chrome/browser/ash/file_manager/file_manager_jstest.cc index 4b66303d1d536..3235191060fe2 100644 --- a/chrome/browser/ash/file_manager/file_manager_jstest.cc +++ b/chrome/browser/ash/file_manager/file_manager_jstest.cc @@ -310,6 +310,10 @@ IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ActionsProducer) { RunTestURL("lib/actions_producer_unittest.js"); } +IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ActionsProducerAllEntries) { + RunTestURL("state/actions_producers/all_entries_unittest.js"); +} + IN_PROC_BROWSER_TEST_F(FileManagerJsTest, BaseStore) { RunTestURL("lib/base_store_unittest.js"); } @@ -318,14 +322,34 @@ IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerAllEntries) { RunTestURL("state/reducers/all_entries_unittest.js"); } +IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerAndroidApps) { + RunTestURL("state/reducers/android_apps_unittest.js"); +} + +IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerFolderShortcuts) { + RunTestURL("state/reducers/folder_shortcuts_unittest.js"); +} + IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerCurrentDirectory) { RunTestURL("state/reducers/current_directory_unittest.js"); } +IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerNavigation) { + RunTestURL("state/reducers/navigation_unittest.js"); +} + IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerSearch) { RunTestURL("state/reducers/search_unittest.js"); } +IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerUiEntries) { + RunTestURL("state/reducers/ui_entries_unittest.js"); +} + +IN_PROC_BROWSER_TEST_F(FileManagerJsTest, ReducerVolumes) { + RunTestURL("state/reducers/volumes_unittest.js"); +} + IN_PROC_BROWSER_TEST_F(FileManagerJsTest, NudgeContainer) { RunTestURL("containers/nudge_container_unittest.js"); } diff --git a/ui/file_manager/file_manager/common/js/entry_utils.ts b/ui/file_manager/file_manager/common/js/entry_utils.ts index 93bfd86231cdd..845add59ff34c 100644 --- a/ui/file_manager/file_manager/common/js/entry_utils.ts +++ b/ui/file_manager/file_manager/common/js/entry_utils.ts @@ -2,9 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import {FilesAppEntry} from '../../externs/files_app_entry_interfaces.js'; import {EntryType, FileData} from '../../externs/ts/state.js'; +import {driveRootEntryListKey, myFilesEntryListKey} from '../../state/reducers/volumes.js'; -import type {VolumeEntry} from './files_app_entry_types.js'; +import {EntryList, FakeEntryImpl, VolumeEntry} from './files_app_entry_types.js'; +import {util} from './util.js'; +import {VolumeManagerCommon} from './volume_manager_types.js'; /** * Type guard used to identify if a generic FileSystemEntry is actually a @@ -37,3 +41,105 @@ export function getNativeEntry(fileData: FileData): Entry|null { } return null; } + +/** + * Type guard used to identify if a given entry is actually a + * VolumeEntry. + */ +export function isVolumeEntry(entry: Entry| + FilesAppEntry): entry is VolumeEntry { + return 'volumeInfo' in entry; +} + +/** + * Check if the entry is MyFiles or not. + * Note: if the return value is true, the input entry is guaranteed to be + * EntryList or VolumeEntry type. + */ +export function isMyFilesEntry(entry: Entry|FilesAppEntry| + null): entry is VolumeEntry|EntryList { + if (!entry) { + return false; + } + if (entry instanceof EntryList && entry.toURL() === myFilesEntryListKey) { + return true; + } + if (isVolumeEntry(entry) && + entry.rootType === VolumeManagerCommon.RootType.DOWNLOADS) { + return true; + } + + return false; +} + +/** + * Check if the entry is the drive root entry list ("Google Drive" wrapper). + * Note: if the return value is true, the input entry is guaranteed to be + * EntryList type. + */ +export function isDriveRootEntryList(entry: Entry|FilesAppEntry| + null): entry is EntryList { + if (!entry) { + return false; + } + return entry.toURL() === driveRootEntryListKey; +} + +/** + * Given an entry, check if it's a grand root ("Shared drives" and + * "Computers") inside Drive. + * Note: if the return value is true, the input entry is guaranteed to be + * DirectoryEntry type. + */ +export function isGrandRootEntryInDrives(entry: Entry|FilesAppEntry): + entry is DirectoryEntry { + const {fullPath} = entry; + return fullPath === VolumeManagerCommon.SHARED_DRIVES_DIRECTORY_PATH || + fullPath === VolumeManagerCommon.COMPUTERS_DIRECTORY_PATH; +} + +/** + * Given an entry, check if it's a fake entry ("Shared with me" and "Offline") + * inside Drive. + */ +export function isFakeEntryInDrives(entry: Entry| + FilesAppEntry): entry is FakeEntryImpl { + if (!(entry instanceof FakeEntryImpl)) { + return false; + } + const {rootType} = entry; + + return rootType === VolumeManagerCommon.RootType.DRIVE_SHARED_WITH_ME || + rootType === VolumeManagerCommon.RootType.DRIVE_OFFLINE; +} + +/** Sort the entries based on the filter and the names. */ +export function sortEntries( + parentEntry: Entry|FilesAppEntry, + entries: Array): Array { + if (entries.length === 0) { + return []; + } + // TODO: proper way to get directory model and volume manager. + const {directoryModel, volumeManager} = window.fileManager; + const fileFilter = directoryModel.getFileFilter(); + // For entries under My Files we need to use a different sorting logic + // because we need to make sure curtain files are always at the bottom. + if (isMyFilesEntry(parentEntry)) { + // Use locationInfo from first entry because it only compare within the + // same volume. + // TODO(b/271485133): Do not use getLocationInfo() for sorting. + const locationInfo = volumeManager.getLocationInfo(entries[0]!); + if (locationInfo) { + const compareFunction = util.compareLabelAndGroupBottomEntries( + locationInfo, + // Only Linux/Play/GuestOS files are in the UI children. + parentEntry.getUIChildren(), + ); + return entries.filter(entry => fileFilter.filter(entry)) + .sort(compareFunction); + } + } + return entries.filter(entry => fileFilter.filter(entry)) + .sort(util.compareName); +} diff --git a/ui/file_manager/file_manager/definitions/file_manager.d.ts b/ui/file_manager/file_manager/definitions/file_manager.d.ts index 1c88d6e87d7e6..1e1788279f130 100644 --- a/ui/file_manager/file_manager/definitions/file_manager.d.ts +++ b/ui/file_manager/file_manager/definitions/file_manager.d.ts @@ -17,6 +17,7 @@ interface FileManager { selectionHandler: FileSelectionHandler; taskController: TaskController; dialogType: DialogType; + directoryModel: DirectoryModel; } /** diff --git a/ui/file_manager/file_manager/externs/ts/state.js b/ui/file_manager/file_manager/externs/ts/state.js index 5758ddb1dab8e..f29f45b592047 100644 --- a/ui/file_manager/file_manager/externs/ts/state.js +++ b/ui/file_manager/file_manager/externs/ts/state.js @@ -36,6 +36,10 @@ export const EntryType = { * * `icon` can be either a string or a IconSet which is an object including * both high/low DPI icon data. * + * TODO(b/271485133): `children` here only store sub directories for now, it + * should store all children including files, it's up to the container to do + * filter and sorting if needed. + * * @typedef {{ * entry: (Entry|FilesAppEntry), * icon: (!string|!chrome.fileManagerPrivate.IconSet), diff --git a/ui/file_manager/file_manager/foreground/js/directory_contents.js b/ui/file_manager/file_manager/foreground/js/directory_contents.js index 5bacadf676443..a18c723fdd120 100644 --- a/ui/file_manager/file_manager/foreground/js/directory_contents.js +++ b/ui/file_manager/file_manager/foreground/js/directory_contents.js @@ -16,7 +16,7 @@ import {createTrashReaders} from '../../common/js/trash.js'; import {util} from '../../common/js/util.js'; import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; import {EntryLocation} from '../../externs/entry_location.js'; -import {FakeEntry, FilesAppDirEntry} from '../../externs/files_app_entry_interfaces.js'; +import {FakeEntry, FilesAppDirEntry, FilesAppEntry} from '../../externs/files_app_entry_interfaces.js'; import {SearchFileType, SearchLocation, SearchOptions, SearchRecency} from '../../externs/ts/state.js'; import {VolumeManager} from '../../externs/volume_manager.js'; import {getDefaultSearchOptions} from '../../state/store.js'; @@ -705,8 +705,8 @@ export class FileFilter extends EventTarget { /** * @param {string} name Filter identifier. - * @param {function(Entry)} callback A filter - a function receiving an Entry, - * and returning bool. + * @param {function((Entry|FilesAppEntry))} callback A filter - a function + * receiving an Entry, and returning bool. */ addFilter(name, callback) { this.filters_[name] = callback; @@ -807,7 +807,7 @@ export class FileFilter extends EventTarget { } /** - * @param {Entry} entry File entry. + * @param {Entry|FilesAppEntry} entry File entry. * @return {boolean} True if the file should be shown, false otherwise. */ filter(entry) { diff --git a/ui/file_manager/file_manager/foreground/js/directory_model.js b/ui/file_manager/file_manager/foreground/js/directory_model.js index 4df57f89b3ad1..0e8f510dc4a7d 100644 --- a/ui/file_manager/file_manager/foreground/js/directory_model.js +++ b/ui/file_manager/file_manager/foreground/js/directory_model.js @@ -1468,6 +1468,8 @@ export class DirectoryModel extends EventTarget { ); }; } + // TODO(b/271485133): Make sure the entry here is a fake entry, not real + // volume entry. if (entry.rootType == VolumeManagerCommon.RootType.CROSTINI) { return () => { return new CrostiniMounter(); diff --git a/ui/file_manager/file_manager/state/actions.ts b/ui/file_manager/file_manager/state/actions.ts index 63cccf208adaa..b389a9a832e92 100644 --- a/ui/file_manager/file_manager/state/actions.ts +++ b/ui/file_manager/file_manager/state/actions.ts @@ -5,8 +5,13 @@ import {SearchData} from '../externs/ts/state.js'; import {BaseAction} from '../lib/base_store.js'; -import {ClearStaleCachedEntriesAction, UpdateMetadataAction} from './actions/all_entries.js'; +import {AddChildEntriesAction, ClearStaleCachedEntriesAction, UpdateMetadataAction} from './actions/all_entries.js'; +import {AddAndroidAppsAction} from './actions/android_apps.js'; import {ChangeDirectoryAction, ChangeFileTasksAction, ChangeSelectionAction, UpdateDirectoryContentAction} from './actions/current_directory.js'; +import {AddFolderShortcutAction, RefreshFolderShortcutAction, RemoveFolderShortcutAction} from './actions/folder_shortcuts.js'; +import {RefreshNavigationRootsAction, UpdateNavigationEntryAction} from './actions/navigation.js'; +import {AddUiEntryAction, RemoveUiEntryAction} from './actions/ui_entries.js'; +import {AddVolumeAction, RemoveVolumeAction} from './actions/volumes.js'; /** * Union of all types of Actions in Files app. @@ -15,13 +20,27 @@ import {ChangeDirectoryAction, ChangeFileTasksAction, ChangeSelectionAction, Upd * A good explanation of this feature is here: * https://mariusschulz.com/blog/tagged-union-types-in-typescript */ -export type Action = ChangeDirectoryAction|ChangeSelectionAction| +export type Action = AddVolumeAction|RemoveVolumeAction| + RefreshNavigationRootsAction|ChangeDirectoryAction|ChangeSelectionAction| ChangeFileTasksAction|ClearStaleCachedEntriesAction|SearchAction| - UpdateDirectoryContentAction|UpdateMetadataAction; + AddUiEntryAction|RemoveUiEntryAction|UpdateDirectoryContentAction| + UpdateMetadataAction|RefreshFolderShortcutAction|AddFolderShortcutAction| + RemoveFolderShortcutAction|AddAndroidAppsAction|AddChildEntriesAction| + UpdateNavigationEntryAction; /** Enum to identify every Action in Files app. */ export const enum ActionType { + ADD_VOLUME = 'add-volume', + REMOVE_VOLUME = 'remove-volume', + ADD_UI_ENTRY = 'add-ui-entry', + REMOVE_UI_ENTRY = 'remove-ui-entry', + REFRESH_FOLDER_SHORTCUT = 'refresh-folder-shortcut', + ADD_FOLDER_SHORTCUT = 'add-folder-shortcut', + REMOVE_FOLDER_SHORTCUT = 'remove-folder-shortcut', + ADD_ANDROID_APPS = 'add-android-apps', + REFRESH_NAVIGATION_ROOTS = 'refresh-navigation-roots', + UPDATE_NAVIGATION_ENTRY = 'update-navigation-entry', CHANGE_DIRECTORY = 'change-directory', CHANGE_SELECTION = 'change-selection', CHANGE_FILE_TASKS = 'change-file-tasks', @@ -29,6 +48,7 @@ export const enum ActionType { SEARCH = 'search', UPDATE_DIRECTORY_CONTENT = 'update-directory-content', UPDATE_METADATA = 'update-metadata', + ADD_CHILD_ENTRIES = 'add-child-entries', } diff --git a/ui/file_manager/file_manager/state/actions/all_entries.ts b/ui/file_manager/file_manager/state/actions/all_entries.ts index 2a7ddc170f59e..01d98e3d59334 100644 --- a/ui/file_manager/file_manager/state/actions/all_entries.ts +++ b/ui/file_manager/file_manager/state/actions/all_entries.ts @@ -6,6 +6,7 @@ import {FilesAppEntry} from '../../externs/files_app_entry_interfaces.js'; import {MetadataItem} from '../../foreground/js/metadata/metadata_item.js'; import {BaseAction} from '../../lib/base_store.js'; import {ActionType} from '../actions.js'; +import {FileKey} from '../file_key.js'; /** * Processes the allEntries and removes any entry that isn't in use any more. @@ -30,6 +31,16 @@ export interface UpdateMetadataAction extends BaseAction { }; } +/** Action to add child entries to a given parent entry. */ +export interface AddChildEntriesAction extends BaseAction { + type: ActionType.ADD_CHILD_ENTRIES; + payload: { + parentKey: FileKey, + entries: Array, + }; +} + + /** Factory for the UpdateMetadataAction. */ export function updateMetadata(payload: UpdateMetadataAction['payload']): UpdateMetadataAction { @@ -38,3 +49,12 @@ export function updateMetadata(payload: UpdateMetadataAction['payload']): payload, }; } + +/** Action factory to add child entries to a given parent entry. */ +export function addChildEntries(payload: AddChildEntriesAction['payload']): + AddChildEntriesAction { + return { + type: ActionType.ADD_CHILD_ENTRIES, + payload, + }; +} diff --git a/ui/file_manager/file_manager/state/actions/android_apps.ts b/ui/file_manager/file_manager/state/actions/android_apps.ts new file mode 100644 index 0000000000000..9eef29adfcdb5 --- /dev/null +++ b/ui/file_manager/file_manager/state/actions/android_apps.ts @@ -0,0 +1,31 @@ +// 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 {BaseAction} from '../../lib/base_store.js'; +import {ActionType} from '../actions.js'; + +/** + * Actions for Android apps. + * + * Android App is something we get from private API + * `chrome.fileManagerPrivate.getAndroidPickerApps`, it will be shown as + * a directory item in FilePicker mode. + */ + +/** Action to add all android app config to the store. */ +export interface AddAndroidAppsAction extends BaseAction { + type: ActionType.ADD_ANDROID_APPS; + payload: { + apps: chrome.fileManagerPrivate.AndroidApp[], + }; +} + +/** Action factory to add all android app config to the store. */ +export function addAndroidApps(payload: AddAndroidAppsAction['payload']): + AddAndroidAppsAction { + return { + type: ActionType.ADD_ANDROID_APPS, + payload, + }; +} diff --git a/ui/file_manager/file_manager/state/actions/folder_shortcuts.ts b/ui/file_manager/file_manager/state/actions/folder_shortcuts.ts new file mode 100644 index 0000000000000..55079abf7f64b --- /dev/null +++ b/ui/file_manager/file_manager/state/actions/folder_shortcuts.ts @@ -0,0 +1,71 @@ +// 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 {FileKey} from '../../externs/ts/state.js'; +import {BaseAction} from '../../lib/base_store.js'; +import {ActionType} from '../actions.js'; + +/** + * Actions for Folder shortcuts. + * + * Folder shortcuts represent a shortcut for the folders inside Drive. + */ + +/** Action to refresh all folder shortcuts in the store. */ +export interface RefreshFolderShortcutAction extends BaseAction { + type: ActionType.REFRESH_FOLDER_SHORTCUT; + payload: { + /** All folder shortcuts should be provided here. */ + entries: DirectoryEntry[], + }; +} + +/** Action to add single folder shortcut in the store. */ +export interface AddFolderShortcutAction extends BaseAction { + type: ActionType.ADD_FOLDER_SHORTCUT; + payload: { + entry: DirectoryEntry, + }; +} + +/** Action to remove single folder shortcut from the store. */ +export interface RemoveFolderShortcutAction extends BaseAction { + type: ActionType.REMOVE_FOLDER_SHORTCUT; + payload: { + key: FileKey, + }; +} + +/** + * Action factory to refresh all folder shortcuts in the store, all folder + * shortcuts needs to be provided here because it will replace all existing ones + * in the store. + */ +export function refreshFolderShortcut( + payload: RefreshFolderShortcutAction['payload']): + RefreshFolderShortcutAction { + return { + type: ActionType.REFRESH_FOLDER_SHORTCUT, + payload, + }; +} + +/** Action factory to add single folder shortcut in the store. */ +export function addFolderShortcut(payload: AddFolderShortcutAction['payload']): + AddFolderShortcutAction { + return { + type: ActionType.ADD_FOLDER_SHORTCUT, + payload, + }; +} + +/** Action factory to remove single folder shortcut in the store. */ +export function removeFolderShortcut( + payload: RemoveFolderShortcutAction['payload']): + RemoveFolderShortcutAction { + return { + type: ActionType.REMOVE_FOLDER_SHORTCUT, + payload, + }; +} diff --git a/ui/file_manager/file_manager/state/actions/navigation.ts b/ui/file_manager/file_manager/state/actions/navigation.ts new file mode 100644 index 0000000000000..a28e0778a4f8f --- /dev/null +++ b/ui/file_manager/file_manager/state/actions/navigation.ts @@ -0,0 +1,47 @@ +// 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 {BaseAction} from '../../lib/base_store.js'; +import {ActionType} from '../actions.js'; +import {FileKey} from '../file_key.js'; + + +/** Action to refresh all navigation roots. */ +export interface RefreshNavigationRootsAction extends BaseAction { + type: ActionType.REFRESH_NAVIGATION_ROOTS; + payload: {}; +} + +/** Action to update the navigation data in FileData for a given entry. */ +export interface UpdateNavigationEntryAction extends BaseAction { + type: ActionType.UPDATE_NAVIGATION_ENTRY; + payload: { + key: FileKey, + expanded: boolean, + }; +} + +/** + * Action factory to refresh all navigation roots. This will clear all existing + * navigation roots in the store and regenerate them with the current state + * data. + */ +export function refreshNavigationRoots(): RefreshNavigationRootsAction { + return { + type: ActionType.REFRESH_NAVIGATION_ROOTS, + payload: {}, + }; +} + +/** + * Action factory to update the navigation data in FileData for a given entry. + */ +export function updateNavigationEntry( + payload: UpdateNavigationEntryAction['payload']): + UpdateNavigationEntryAction { + return { + type: ActionType.UPDATE_NAVIGATION_ENTRY, + payload, + }; +} diff --git a/ui/file_manager/file_manager/state/actions/ui_entries.ts b/ui/file_manager/file_manager/state/actions/ui_entries.ts new file mode 100644 index 0000000000000..32b5f13ad2e51 --- /dev/null +++ b/ui/file_manager/file_manager/state/actions/ui_entries.ts @@ -0,0 +1,51 @@ +// 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 {FakeEntryImpl} from '../../common/js/files_app_entry_types.js'; +import {FileKey} from '../../externs/ts/state.js'; +import {BaseAction} from '../../lib/base_store.js'; +import {ActionType} from '../actions.js'; + +/** + * Actions for UI entries. + * + * UI entries represents entries shown on UI only (aka FakeEntry, e.g. + * Recents/Trash/Google Drive wrapper), they don't have a real entry backup in + * the file system. + */ + + +/** Action add single UI entry into the store. */ +export interface AddUiEntryAction extends BaseAction { + type: ActionType.ADD_UI_ENTRY; + payload: { + entry: FakeEntryImpl, + }; +} + +/** Action remove single UI entry from the store. */ +export interface RemoveUiEntryAction extends BaseAction { + type: ActionType.REMOVE_UI_ENTRY; + payload: { + key: FileKey, + }; +} + +/** Action factory to add single UI entry into the store. */ +export function addUiEntry(payload: AddUiEntryAction['payload']): + AddUiEntryAction { + return { + type: ActionType.ADD_UI_ENTRY, + payload, + }; +} + +/** Action factory to remove single UI entry from the store. */ +export function removeUiEntry(payload: RemoveUiEntryAction['payload']): + RemoveUiEntryAction { + return { + type: ActionType.REMOVE_UI_ENTRY, + payload, + }; +} diff --git a/ui/file_manager/file_manager/state/actions/volumes.ts b/ui/file_manager/file_manager/state/actions/volumes.ts new file mode 100644 index 0000000000000..3297fa4a5a30a --- /dev/null +++ b/ui/file_manager/file_manager/state/actions/volumes.ts @@ -0,0 +1,48 @@ +// 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 {VolumeId} from '../../externs/ts/state.js'; +import {VolumeInfo} from '../../externs/volume_info.js'; +import {BaseAction} from '../../lib/base_store.js'; +import {ActionType} from '../actions.js'; + + +/** Action to add single volume into the store. */ +export interface AddVolumeAction extends BaseAction { + type: ActionType.ADD_VOLUME; + payload: { + volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata, + volumeInfo: VolumeInfo, + }; +} + + +/** Action to remove single volume from the store. */ +export interface RemoveVolumeAction extends BaseAction { + type: ActionType.REMOVE_VOLUME; + payload: { + volumeId: VolumeId, + }; +} + +/** Action factory to add single volume into the store. */ +export function addVolume( + {volumeMetadata, volumeInfo}: AddVolumeAction['payload']): AddVolumeAction { + return { + type: ActionType.ADD_VOLUME, + payload: { + volumeMetadata, + volumeInfo, + }, + }; +} + +/** Action factory to remove single volume from the store. */ +export function removeVolume({volumeId}: RemoveVolumeAction['payload']): + RemoveVolumeAction { + return { + type: ActionType.REMOVE_VOLUME, + payload: {volumeId}, + }; +} diff --git a/ui/file_manager/file_manager/state/actions_producers/all_entries.ts b/ui/file_manager/file_manager/state/actions_producers/all_entries.ts new file mode 100644 index 0000000000000..0b53764b623f8 --- /dev/null +++ b/ui/file_manager/file_manager/state/actions_producers/all_entries.ts @@ -0,0 +1,141 @@ +// 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 {DialogType} from '../../common/js/dialog_type.js'; +import {isDriveRootEntryList, isFakeEntryInDrives, isGrandRootEntryInDrives, sortEntries} from '../../common/js/entry_utils.js'; +import {EntryList} from '../../common/js/files_app_entry_types.js'; +import {metrics} from '../../common/js/metrics.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {FilesAppDirEntry, FilesAppEntry} from '../../externs/files_app_entry_interfaces.js'; +import {ActionsProducerGen} from '../../lib/actions_producer.js'; +import {addChildEntries, AddChildEntriesAction} from '../actions/all_entries.js'; + +/** + * @fileoverview Action producers related to entries. + * @suppress {checkTypes} TS already checks this file. + */ + +/** + * Read sub directories for a given entry. + * TODO(b/271485133): Remove successCallback/errorCallback. + */ +export async function* + readSubDirectories( + entry: Entry|FilesAppEntry|null, + successCallback?: (entries: Array) => void, + errorCallback?: () => void): ActionsProducerGen { + if (!entry || !entry.isDirectory || ('disabled' in entry && entry.disabled)) { + errorCallback && errorCallback(); + return; + } + + // Type casting here because TS can't exclude the invalid entry types via the + // above if checks. + const validEntry = entry as DirectoryEntry | FilesAppDirEntry; + if (isDriveRootEntryList(validEntry)) { + for await ( + const action of readSubDirectoriesForDriveRootEntryList(validEntry)) { + yield action; + } + } else { + const childEntries = await readChildEntriesForDirectoryEntry(validEntry); + // Only dispatch directories. + const subDirectories = + childEntries.filter(childEntry => childEntry.isDirectory); + successCallback && successCallback(subDirectories); + yield addChildEntries({parentKey: entry.toURL(), entries: subDirectories}); + } +} + +/** + * Read entries for Drive root entry list (aka "Google Drive"), there are some + * differences compared to the `readSubDirectoriesForDirectoryEntry`: + * * We don't need to call readEntries to get its child entries. Instead, all + * its children are from its entry.getUIChildren(). + * * For fake entries children (e.g. Shared with me and Offline), we only show + * them based on the dialog type. + * * For curtain children (e.g. team drives and computers grand root), we only + * show them when there's at least one child entries inside. So we need to read + * their children (grand children of drive fake root) first before we can decide + * if we need to show them or not. + */ +async function* + readSubDirectoriesForDriveRootEntryList(entry: EntryList): + ActionsProducerGen { + const metricNameMap = { + [VolumeManagerCommon.SHARED_DRIVES_DIRECTORY_PATH]: 'TeamDrivesCount', + [VolumeManagerCommon.COMPUTERS_DIRECTORY_PATH]: 'ComputerCount', + }; + + const driveChildren = entry.getUIChildren(); + /** + * Store the filtered children, for fake entries or grand roots we might need + * to hide them based on curtain conditions. + */ + const filteredChildren: Array = []; + const grandChildEntriesToDispatch: + Array<{key: string, entries: Array}> = []; + + const isFakeEntryVisible = + window.fileManager.dialogType !== DialogType.SELECT_SAVEAS_FILE; + + for (const childEntry of driveChildren) { + // For fake entries ("Shared with me" and) + if (isFakeEntryInDrives(childEntry)) { + if (isFakeEntryVisible) { + filteredChildren.push(childEntry); + } + continue; + } + // For non grand roots (also not fake entries), we put them in the children + // directly and dispatch an action to read the it later. + if (!isGrandRootEntryInDrives(childEntry)) { + filteredChildren.push(childEntry); + continue; + } + // For grand roots ("Shared drives" and "Computers") inside Drive, we only + // show them when there's at least one child entries inside. + const grandChildEntries = + await readChildEntriesForDirectoryEntry(childEntry); + metrics.recordSmallCount( + metricNameMap[childEntry.fullPath]!, grandChildEntries.length); + if (grandChildEntries.length > 0) { + filteredChildren.push(childEntry); + grandChildEntriesToDispatch.push({ + key: childEntry.toURL(), + // Only dispatch directories. + entries: grandChildEntries.filter(entry => entry.isDirectory), + }); + } + } + yield addChildEntries({parentKey: entry.toURL(), entries: filteredChildren}); + for (const item of grandChildEntriesToDispatch) { + yield addChildEntries({parentKey: item.key, entries: item.entries}); + } +} + +/** + * Read child entries for a given directory entry. + */ +async function readChildEntriesForDirectoryEntry( + entry: DirectoryEntry| + FilesAppDirEntry): Promise> { + return new Promise>(resolve => { + const reader = entry.createReader(); + const subEntries: Array = []; + const readEntry = () => { + reader.readEntries((entries) => { + if (entries.length === 0) { + resolve(sortEntries(entry, subEntries)); + return; + } + for (const subEntry of entries) { + subEntries.push(subEntry); + } + readEntry(); + }); + }; + readEntry(); + }); +} diff --git a/ui/file_manager/file_manager/state/actions_producers/all_entries_unittest.ts b/ui/file_manager/file_manager/state/actions_producers/all_entries_unittest.ts new file mode 100644 index 0000000000000..2e634523e69fd --- /dev/null +++ b/ui/file_manager/file_manager/state/actions_producers/all_entries_unittest.ts @@ -0,0 +1,203 @@ +// 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 {assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js'; + +import {EntryList, FakeEntryImpl, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {metrics} from '../../common/js/metrics.js'; +import {MockFileSystem} from '../../common/js/mock_entry.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {State} from '../../externs/ts/state.js'; +import {constants} from '../../foreground/js/constants.js'; +import {setUpFileManagerOnWindow, setupStore, waitDeepEquals} from '../for_tests.js'; +import {convertEntryToFileData} from '../reducers/all_entries.js'; + +import {readSubDirectories} from './all_entries.js'; + +// Global variables to check if the callback is called or not. +let isSuccessCallbackCalled = false; +let isErrorCallbackCalled = false; + +function successCallback() { + isSuccessCallbackCalled = true; +} +function errorCallback() { + isErrorCallbackCalled = true; +} + +export function setUp() { + // sortEntries() requires the directoryModel on the window.fileManager. + setUpFileManagerOnWindow(); + // Mock metrics.recordSmallCount. + metrics.recordSmallCount = function(_name, _value) {}; + // Reset global callback flags. + isSuccessCallbackCalled = false; + isErrorCallbackCalled = false; +} + +/** + * Tests that reading sub directories will put the reading result into the + * store. + */ +export async function testReadSubDirectories(done: () => void) { + const store = setupStore(); + // Populate fake entries in the file system. + const {volumeManager} = window.fileManager; + const downloadsVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const fakeFs = downloadsVolumeInfo.fileSystem as MockFileSystem; + fakeFs.populate([ + '/Downloads/', + '/Downloads/c/', + '/Downloads/b.txt', + '/Downloads/a/', + ]); + const downloadsEntry = fakeFs.entries['/Downloads']; + // The entry to be read should be in the store before reading. + const downloadsEntryFileData = convertEntryToFileData(downloadsEntry); + store.getState().allEntries[downloadsEntry.toURL()] = downloadsEntryFileData; + + // Dispatch read sub directories action producer. + store.dispatch( + readSubDirectories(downloadsEntry, successCallback, errorCallback)); + + // Expect store to have all its sub directories. + const aDirEntry = fakeFs.entries['/Downloads/a']; + const cDirEntry = fakeFs.entries['/Downloads/c']; + const want: State['allEntries'] = { + [downloadsEntry.toURL()]: downloadsEntryFileData, + [aDirEntry.toURL()]: convertEntryToFileData(aDirEntry), + [cDirEntry.toURL()]: convertEntryToFileData(cDirEntry), + }; + want[downloadsEntry.toURL()].children = + [aDirEntry.toURL(), cDirEntry.toURL()]; + + await waitDeepEquals(store, want, (state) => state.allEntries); + assertTrue(isSuccessCallbackCalled); + assertFalse(isErrorCallbackCalled); + + done(); +} + +/** Tests that reading a null entry does nothing. */ +export async function testReadSubDirectoriesWithNullEntry(done: () => void) { + const store = setupStore(); + + // Dispatch read sub directories action producer. + store.dispatch(readSubDirectories(null, successCallback, errorCallback)); + + await waitDeepEquals(store, {}, (state) => state.allEntries); + assertFalse(isSuccessCallbackCalled); + assertTrue(isErrorCallbackCalled); + + done(); +} + +/** Tests that reading a non directory entry does nothing. */ +export async function testReadSubDirectoriesWithNonDirectoryEntry( + done: () => void) { + const store = setupStore(); + + // Populate a fake file entry in the file system. + const fakeFs = new MockFileSystem('fake-fs'); + fakeFs.populate([ + '/a.txt', + ]); + + // Check reading non directory entry will call error callback. + store.dispatch(readSubDirectories( + fakeFs.entries['/a.txt'], successCallback, errorCallback)); + + await waitDeepEquals(store, {}, (state) => state.allEntries); + assertFalse(isSuccessCallbackCalled); + assertTrue(isErrorCallbackCalled); + + done(); +} + +/** Tests that reading a disabled entry does nothing. */ +export async function testReadSubDirectoriesWithDisabledEntry() { + const store = setupStore(); + + // Make downloadsEntry as disabled. + const {volumeManager} = window.fileManager; + const downloadsVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const downloadsEntry = new VolumeEntry(downloadsVolumeInfo); + downloadsEntry.disabled = true; + + // Check reading disabled volume entry will call error callback. + store.dispatch( + readSubDirectories(downloadsEntry, successCallback, errorCallback)); + + assertFalse(isSuccessCallbackCalled); + assertTrue(isErrorCallbackCalled); +} + +/** + * Tests that reading sub directories for fake drive entry will put the reading + * result into the store and handle grand roots properly. + */ +export async function testReadSubDirectoriesForFakeDriveEntry( + done: () => void) { + const store = setupStore(); + // MockVolumeManager will populate Drive's /root, /team_drives, /Computers + // automatically. + const {volumeManager} = window.fileManager; + const driveVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DRIVE)!; + const driveFs = driveVolumeInfo.fileSystem as MockFileSystem; + + // Create drive root entry list and add all its children. + const driveRootEntryList = new EntryList( + 'Google Drive', VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT); + const sharedWithMeEntry = new FakeEntryImpl( + 'Shared with me', VolumeManagerCommon.RootType.DRIVE_SHARED_WITH_ME); + const offlineEntry = + new FakeEntryImpl('Offline', VolumeManagerCommon.RootType.DRIVE_OFFLINE); + const driveEntry = driveFs.entries['/root']; + const computersEntry = driveFs.entries['/Computers']; + driveRootEntryList.addEntry(driveEntry); + driveRootEntryList.addEntry(driveFs.entries['/team_drives']); + driveRootEntryList.addEntry(computersEntry); + driveRootEntryList.addEntry(sharedWithMeEntry); + driveRootEntryList.addEntry(offlineEntry); + // Add child entries. + driveFs.populate([ + '/root/a/', + '/root/b.txt', + '/Computers/c.txt', + ]); + // Drive root entry list needs to be in the store before reading. + const fakeDriveEntryFileData = convertEntryToFileData(driveRootEntryList); + store.getState().allEntries[driveRootEntryList.toURL()] = + fakeDriveEntryFileData; + + // Dispatch read sub directories action producer. + store.dispatch( + readSubDirectories(driveRootEntryList, successCallback, errorCallback)); + + // Expect its direct sub directories and grand sub directories of /Computers + // should be in the store. + const want: State['allEntries'] = { + [driveRootEntryList.toURL()]: fakeDriveEntryFileData, + [driveEntry.toURL()]: convertEntryToFileData(driveEntry), + [computersEntry.toURL()]: convertEntryToFileData(computersEntry), + [sharedWithMeEntry.toURL()]: convertEntryToFileData(sharedWithMeEntry), + [offlineEntry.toURL()]: convertEntryToFileData(offlineEntry), + // /team_drives/ won't be here because it doesn't have children. + }; + want[computersEntry.toURL()].icon = constants.ICON_TYPES.COMPUTERS_GRAND_ROOT; + want[driveRootEntryList.toURL()].children = [ + driveEntry.toURL(), + computersEntry.toURL(), + sharedWithMeEntry.toURL(), + offlineEntry.toURL(), + ]; + // /root children won't be read. + + await waitDeepEquals(store, want, (state) => state.allEntries); + + done(); +} diff --git a/ui/file_manager/file_manager/state/for_tests.ts b/ui/file_manager/file_manager/state/for_tests.ts index e1fe8790c8f64..bcca8052f0fe2 100644 --- a/ui/file_manager/file_manager/state/for_tests.ts +++ b/ui/file_manager/file_manager/state/for_tests.ts @@ -4,12 +4,23 @@ import {assertDeepEquals} from 'chrome://webui-test/chromeos/chai_assert.js'; +import {MockVolumeManager} from '../background/js/mock_volume_manager.js'; +import {DialogType} from '../common/js/dialog_type.js'; +import {VolumeManagerCommon} from '../common/js/volume_manager_types.js'; +import {Crostini} from '../externs/background/crostini.js'; import {FilesAppDirEntry} from '../externs/files_app_entry_interfaces.js'; -import {FileKey, PropStatus, State} from '../externs/ts/state.js'; +import {FileData, FileKey, PropStatus, State, Volume} from '../externs/ts/state.js'; +import {constants} from '../foreground/js/constants.js'; +import {FileSelectionHandler} from '../foreground/js/file_selection.js'; +import {MetadataItem} from '../foreground/js/metadata/metadata_item.js'; +import {MetadataModel} from '../foreground/js/metadata/metadata_model.js'; +import {MockMetadataModel} from '../foreground/js/metadata/mock_metadata.js'; +import {createFakeDirectoryModel} from '../foreground/js/mock_directory_model.js'; +import {TaskController} from '../foreground/js/task_controller.js'; import {EntryMetadata, updateMetadata} from './actions/all_entries.js'; import {changeDirectory, updateDirectoryContent, updateSelection} from './actions/current_directory.js'; -import {StateSelector, Store, waitForState} from './store.js'; +import {getEmptyState, getStore, StateSelector, Store, waitForState} from './store.js'; /** * Compares 2 State objects and fails with nicely formatted message when it @@ -102,3 +113,124 @@ export async function waitDeepEquals( await Promise.race([checker, timeout]); } + +/** Setup store and initialize it with empty state. */ +export function setupStore(): Store { + const store = getStore(); + store.init(getEmptyState()); + return store; +} + +/** + * Setup fileManager dependencies on window object. + */ +export function setUpFileManagerOnWindow() { + const volumeManager = new MockVolumeManager(); + // Keys are defined in file_manager.d.ts + window.fileManager = { + volumeManager: volumeManager, + metadataModel: new MockMetadataModel({}) as unknown as MetadataModel, + crostini: {} as unknown as Crostini, + selectionHandler: {} as unknown as FileSelectionHandler, + taskController: {} as unknown as TaskController, + dialogType: DialogType.FULL_PAGE, + directoryModel: createFakeDirectoryModel(), + }; +} + +/** + * Create a fake FileData with partial information. Only the fields listed are + * required, other fields are optional. + */ +export function createFakeFileData( + partialFileData: Pick& + Partial>, + ): FileData { + const defaultFileData = { + icon: constants.ICON_TYPES.FOLDER, + volumeType: null, + isDirectory: true, + metadata: {} as MetadataItem, + isRootEntry: false, + isEjectable: false, + shouldDelayLoadingChildren: false, + children: [], + expanded: false, + }; + return { + ...defaultFileData, + ...partialFileData, + }; +} + +/** Create a fake VolumeMetadata. */ +export function createFakeVolumeMetadata( + partialMetadata: + Pick& + Partial>, + ): chrome.fileManagerPrivate.VolumeMetadata { + const defaultMetadata = { + profile: { + displayName: 'foobar@chromium.org', + isCurrentProfile: true, + profileId: '', + }, + configurable: false, + watchable: true, + source: VolumeManagerCommon.Source.SYSTEM, + volumeLabel: undefined, + fileSystemId: undefined, + providerId: undefined, + sourcePath: undefined, + deviceType: undefined, + devicePath: undefined, + isParentDevice: undefined, + isReadOnly: false, + isReadOnlyRemovableDevice: false, + hasMedia: false, + mountCondition: undefined, + mountContext: undefined, + diskFileSystemType: undefined, + iconSet: {icon16x16Url: '', icon32x32Url: ''}, + driveLabel: '', + remoteMountPath: undefined, + hidden: false, + vmType: undefined, + }; + return { + ...defaultMetadata, + ...partialMetadata, + }; +} + +/** + * Create a fake Volume. Only the fields listed are required, other fields are + * optional. + */ +export function createFakeVolume( + partialVolume: Pick& + Partial>): Volume { + const defaultVolume = { + status: PropStatus.SUCCESS, + source: VolumeManagerCommon.Source.SYSTEM, + error: undefined, + deviceType: undefined, + devicePath: undefined, + isReadOnly: false, + isReadOnlyRemovableDevice: false, + providerId: undefined, + configurable: false, + watchable: true, + diskFileSystemType: '', + iconSet: {icon16x16Url: '', icon32x32Url: ''}, + driveLabel: '', + vmType: undefined, + isDisabled: false, + prefixKey: undefined, + }; + return { + ...defaultVolume, + ...partialVolume, + }; +} diff --git a/ui/file_manager/file_manager/state/reducers/all_entries.ts b/ui/file_manager/file_manager/state/reducers/all_entries.ts index 197a14c5b3272..b43cbec906e65 100644 --- a/ui/file_manager/file_manager/state/reducers/all_entries.ts +++ b/ui/file_manager/file_manager/state/reducers/all_entries.ts @@ -2,17 +2,23 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import {isVolumeEntry, sortEntries} from '../../common/js/entry_utils.js'; import {FileType} from '../../common/js/file_type.js'; -import {util} from '../../common/js/util.js'; +import {EntryList, FakeEntryImpl, GuestOsPlaceholder, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {str, util} from '../../common/js/util.js'; import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {EntryLocation} from '../../externs/entry_location.js'; import {FilesAppEntry} from '../../externs/files_app_entry_interfaces.js'; -import {CurrentDirectory, EntryType, State} from '../../externs/ts/state.js'; +import {CurrentDirectory, EntryType, FileData, State, Volume, VolumeMap} from '../../externs/ts/state.js'; +import {VolumeInfo} from '../../externs/volume_info.js'; import {constants} from '../../foreground/js/constants.js'; +import {MetadataItem} from '../../foreground/js/metadata/metadata_item.js'; import {Action, ActionType} from '../actions.js'; -import {ClearStaleCachedEntriesAction, UpdateMetadataAction} from '../actions/all_entries.js'; -import {getStore} from '../store.js'; +import {AddChildEntriesAction, ClearStaleCachedEntriesAction, UpdateMetadataAction} from '../actions/all_entries.js'; +import {getEntry, getFileData, getStore} from '../store.js'; import {hasDlpDisabledFiles} from './current_directory.js'; +import {driveRootEntryListKey, getVolumeTypesNestedInMyFiles, makeRemovableParentKey, myFilesEntryListKey, recentRootKey, removableGroupKey} from './volumes.js'; /** * Schedules the routine to remove stale entries from `allEntries`. @@ -61,6 +67,53 @@ export function clearCachedEntries( } } + for (const volume of Object.values(state.volumes)) { + if (!volume.rootKey) { + continue; + } + entriesToKeep.add(volume.rootKey); + if (volume.prefixKey) { + entriesToKeep.add(volume.prefixKey); + } + } + + for (const key of state.uiEntries) { + entriesToKeep.add(key); + } + + for (const key of state.folderShortcuts) { + entriesToKeep.add(key); + } + + for (const root of state.navigation.roots) { + entriesToKeep.add(root.key); + } + + // For all expanded entries, we need to keep them and all their direct + // children. + for (const key of Object.keys(entries)) { + const fileData = entries[key]; + if (fileData.expanded) { + entriesToKeep.add(key); + if (fileData.children) { + for (const child of fileData.children) { + entriesToKeep.add(child); + } + } + } + } + + // For all kept entries, we also need to keep their children so we can decide + // if we need to show the expand icon or not. + for (const key of entriesToKeep) { + const fileData = entries[key]; + if (fileData?.children) { + for (const child of fileData.children) { + entriesToKeep.add(child); + } + } + } + for (const key of Object.keys(entries)) { if (entriesToKeep.has(key)) { continue; @@ -79,36 +132,157 @@ const prefetchPropertyNames = Array.from(new Set([ ...constants.DLP_METADATA_PREFETCH_PROPERTY_NAMES, ])); +/** Get the icon for an entry. */ +function getEntryIcon( + entry: Entry|FilesAppEntry, locationInfo: EntryLocation|null, + volumeType: VolumeManagerCommon.VolumeType|null): FileData['icon'] { + const url = entry.toURL(); + + // Pre-defined icons based on the URL. + const urlToIconPath = { + [recentRootKey]: constants.ICON_TYPES.RECENT, + [myFilesEntryListKey]: constants.ICON_TYPES.MY_FILES, + [driveRootEntryListKey]: constants.ICON_TYPES.SERVICE_DRIVE, + }; + + if (urlToIconPath[url]) { + return urlToIconPath[url]!; + } + + // Handle icons for grand roots ("Shared drives" and "Computers") in Drive. + // Here we can't just use `fullPath` to check if an entry is a grand root or + // not, because normal directory can also have the same full path. We also + // need to check if the entry is a direct child of the drive root entry list. + const grandRootPathToIconMap = { + [VolumeManagerCommon.COMPUTERS_DIRECTORY_PATH]: + constants.ICON_TYPES.COMPUTERS_GRAND_ROOT, + [VolumeManagerCommon.SHARED_DRIVES_DIRECTORY_PATH]: + constants.ICON_TYPES.SHARED_DRIVES_GRAND_ROOT, + }; + if (volumeType === VolumeManagerCommon.VolumeType.DRIVE && + grandRootPathToIconMap[entry.fullPath]) { + return grandRootPathToIconMap[entry.fullPath]!; + } + + // For grouped removable devices, its parent folder is an entry list, we + // should use USB icon for it. + if ('rootType' in entry && + entry.rootType === VolumeManagerCommon.VolumeType.REMOVABLE) { + return constants.ICON_TYPES.USB; + } + + if (isVolumeEntry(entry) && entry.volumeInfo) { + switch (entry.volumeInfo.volumeType) { + case VolumeManagerCommon.VolumeType.DOWNLOADS: + return constants.ICON_TYPES.MY_FILES; + case VolumeManagerCommon.VolumeType.SMB: + return constants.ICON_TYPES.SMB; + case VolumeManagerCommon.VolumeType.PROVIDED: + // TODO: FSP icon + return ''; + case VolumeManagerCommon.VolumeType.DOCUMENTS_PROVIDER: + return entry.volumeInfo.iconSet!; + case VolumeManagerCommon.VolumeType.MTP: + return constants.ICON_TYPES.MTP; + case VolumeManagerCommon.VolumeType.ARCHIVE: + return constants.ICON_TYPES.ARCHIVE; + case VolumeManagerCommon.VolumeType.REMOVABLE: + // For sub-partition from a removable volume, its children icon should + // be UNKNOWN_REMOVABLE. + return entry.volumeInfo.prefixEntry ? + constants.ICON_TYPES.UNKNOWN_REMOVABLE : + constants.ICON_TYPES.USB; + case VolumeManagerCommon.VolumeType.DRIVE: + return constants.ICON_TYPES.DRIVE; + } + } + + return FileType.getIcon(entry as Entry, undefined, locationInfo?.rootType); +} + +function appendChildIfNotExisted( + parentEntry: VolumeEntry|EntryList, + childEntry: Entry|FilesAppEntry): boolean { + if (!parentEntry.getUIChildren().find( + (entry) => util.isSameEntry(entry, childEntry))) { + parentEntry.addEntry(childEntry); + return true; + } + + return false; +} + /** - * Converts the entry to the Store representation of an Entry and appends the - * entry to the Store. + * Converts the entry to the Store representation of an Entry: FileData. */ -function appendEntry(state: State, entry: Entry|FilesAppEntry) { - const allEntries = state.allEntries || {}; - const key = entry.toURL(); +export function convertEntryToFileData(entry: Entry|FilesAppEntry): FileData { + // TODO: get VolumeManager/MetadataModel properly. const volumeManager = window.fileManager?.volumeManager; const metadataModel = window.fileManager?.metadataModel; const volumeInfo = volumeManager?.getVolumeInfo(entry); const locationInfo = volumeManager?.getLocationInfo(entry); - const label = locationInfo ? util.getEntryLabel(locationInfo, entry) : ''; - + // getEntryLabel() can accept locationInfo=null, but TS doesn't recognize the + // type definition in closure, hence the ! here. + const label = util.getEntryLabel(locationInfo!, entry); const volumeType = volumeInfo?.volumeType || null; + const icon = getEntryIcon(entry, locationInfo, volumeType); + + // TODO(b/271485133): populate rootType for VolumeEntry in its constructor, + // add volumeType property to FakeEntry so we don't need the following nested + // if logic to check. + if (entry instanceof VolumeEntry) { + entry.disabled = volumeManager?.isDisabled(volumeType!); + } else if (entry instanceof FakeEntryImpl) { + if (entry.rootType === VolumeManagerCommon.RootType.CROSTINI) { + entry.disabled = volumeManager?.isDisabled(entry.rootType); + } else if (entry instanceof GuestOsPlaceholder) { + if (entry.vm_type == chrome.fileManagerPrivate.VmType.ARCVM) { + entry.disabled = volumeManager.isDisabled( + VolumeManagerCommon.VolumeType.ANDROID_FILES); + } else { + entry.disabled = + volumeManager.isDisabled(VolumeManagerCommon.VolumeType.GUEST_OS); + } + } + } - const entryData = allEntries[key] || {}; const metadata = metadataModel ? - metadataModel.getCache([entry as FileEntry], prefetchPropertyNames)[0] : - undefined; + metadataModel.getCache([entry as FileEntry], prefetchPropertyNames)[0]! : + {} as MetadataItem; - allEntries[key] = { - ...entryData, + return { entry, - iconName: - FileType.getIcon(entry as Entry, undefined, locationInfo?.rootType), + icon, type: getEntryType(entry), isDirectory: entry.isDirectory, label, volumeType, metadata, + expanded: false, + isRootEntry: !!locationInfo?.isRootEntry, + // `isEjectable/shouldDelayLoadingChildren` is determined by its + // corresponding volume, will be updated when volume is added. + isEjectable: false, + shouldDelayLoadingChildren: false, + children: [], + }; +} + +/** + * Appends the entry to the Store. + */ +function appendEntry(state: State, entry: Entry|FilesAppEntry) { + const allEntries = state.allEntries || {}; + const key = entry.toURL(); + const existingFileData = allEntries[key] || {}; + const fileData = convertEntryToFileData(entry); + + allEntries[key] = { + ...existingFileData, + ...fileData, + // if the entry is existed, we want to keep the existing + // children to prevent sudden removal of the children items on the UI. + children: existingFileData.children || [], }; state.allEntries = allEntries; @@ -134,12 +308,35 @@ export function cacheEntries(currentState: State, action: Action): State { appendEntry(currentState, entry); } + if (action.type === ActionType.UPDATE_METADATA) { for (const entryMetadata of action.payload.metadata) { appendEntry(currentState, entryMetadata.entry); } } + if (action.type === ActionType.ADD_VOLUME) { + appendEntry(currentState, new VolumeEntry(action.payload.volumeInfo)); + volumeNestingEntries( + currentState, action.payload.volumeInfo, action.payload.volumeMetadata); + } + if (action.type === ActionType.ADD_UI_ENTRY) { + appendEntry(currentState, action.payload.entry); + } + if (action.type === ActionType.REFRESH_FOLDER_SHORTCUT) { + for (const entry of action.payload.entries) { + appendEntry(currentState, entry); + } + } + if (action.type === ActionType.ADD_FOLDER_SHORTCUT) { + appendEntry(currentState, action.payload.entry); + } + if (action.type === ActionType.ADD_CHILD_ENTRIES) { + for (const entry of action.payload.entries) { + appendEntry(currentState, entry); + } + } + return currentState; } @@ -207,3 +404,272 @@ export function updateMetadata( currentDirectory, }; } + +function findVolumeByType( + volumes: VolumeMap, volumeType: VolumeManagerCommon.VolumeType): Volume| + null { + return Object.values(volumes).find(v => { + // If the volume isn't resolved yet, we just ignore here. + return v.rootKey && v.volumeType === volumeType; + }) ?? + null; +} + +/** + * Returns the MyFiles entry and volume, the entry can either be a fake one + * (EntryList) or a real one (VolumeEntry) depends on if the MyFiles volume is + * mounted or not. + * Note: it will create a fake EntryList in the store if there's no + * MyFiles entry in the store (e.g. no EntryList and no VolumeEntry). + */ +export function getMyFiles(state: State): + {myFilesVolume: null|Volume, myFilesEntry: VolumeEntry|EntryList} { + const {volumes} = state; + const myFilesVolume = + findVolumeByType(volumes, VolumeManagerCommon.VolumeType.DOWNLOADS); + const myFilesVolumeEntry = myFilesVolume ? + getEntry(state, myFilesVolume.rootKey!) as VolumeEntry | null : + null; + let myFilesEntryList = + getEntry(state, myFilesEntryListKey) as EntryList | null; + if (!myFilesVolumeEntry && !myFilesEntryList) { + myFilesEntryList = new EntryList( + str('MY_FILES_ROOT_LABEL'), VolumeManagerCommon.RootType.MY_FILES); + appendEntry(state, myFilesEntryList); + } + + return { + myFilesEntry: myFilesVolumeEntry || myFilesEntryList!, + myFilesVolume, + }; +} + +/** + * It nests the Android, Crostini & GuestOSes inside MyFiles. + * It creates a placeholder for MyFiles if MyFiles volume isn't mounted yet. + * + * It nests the Drive root (aka MyDrive) inside a EntryList for "Google Drive". + * It nests the fake entries for "Offline" and "Shared with me" in "Google + * Drive". + * + * For removables, it may nest in a EntryList if one device has multiple + * partitions. + */ +function volumeNestingEntries( + state: State, volumeInfo: VolumeInfo, + volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata) { + const VolumeType = VolumeManagerCommon.VolumeType; + const myFilesNestedVolumeTypes = getVolumeTypesNestedInMyFiles(); + + const volumeRootKey = volumeInfo.displayRoot?.toURL(); + const newVolumeEntry = getEntry(state, volumeRootKey) as VolumeEntry | null; + + // Do nothing if the volume is not resolved. + if (!volumeInfo || !newVolumeEntry) { + return; + } + + // For volumes which are supposed to be nested inside MyFiles (e.g. Android, + // Crostini, GuestOS), we need to nest them into MyFiles and remove the + // placeholder fake entry if existed. + const {myFilesEntry} = getMyFiles(state); + if (myFilesNestedVolumeTypes.has(volumeInfo.volumeType)) { + const myFilesEntryKey = myFilesEntry.toURL(); + const myFilesFileData = getFileData(state, myFilesEntryKey)!; + // Nest the entry for the new volume info in MyFiles. + for (const childEntry of myFilesEntry.getUIChildren()) { + // Remove a placeholder for the currently mounting volume. + if (childEntry.name === newVolumeEntry.name) { + myFilesEntry.removeChildEntry(childEntry); + // Also remove it from the children field. + myFilesFileData.children = myFilesFileData.children.filter( + childKey => childKey !== childEntry.toURL()); + } + } + appendChildIfNotExisted(myFilesEntry, newVolumeEntry); + // Push the new entry to the children of FileData and sort them. + if (!myFilesFileData.children.find( + childKey => childKey === volumeRootKey)) { + myFilesFileData.children.push(volumeRootKey); + const childEntries = + myFilesFileData.children.map(childKey => getEntry(state, childKey)!); + myFilesFileData.children = + sortEntries(myFilesEntry, childEntries).map(entry => entry.toURL()); + } + state.allEntries[myFilesEntryKey] = {...myFilesFileData}; + } + + // When mounting MyFiles replace the temporary placeholder entry. + if (volumeInfo.volumeType === VolumeType.DOWNLOADS) { + // Do not use myFilesEntry above, because at this moment both fake MyFiles + // and real MyFiles are in the store. + const myFilesEntryList = getEntry(state, myFilesEntryListKey) as EntryList; + const myFilesVolumeEntry = newVolumeEntry; + if (myFilesEntryList) { + // We need to copy the children of the entry list to the real volume + // entry. + const uiChildren = [...myFilesEntryList.getUIChildren()]; + for (const childEntry of uiChildren) { + appendChildIfNotExisted(myFilesVolumeEntry!, childEntry); + myFilesEntryList.removeChildEntry(childEntry); + } + } + } + + // Drive fake entries for root for: Shared Drives, Computers and the parent + // Google Drive. + if (volumeInfo.volumeType === VolumeType.DRIVE) { + const myDrive = newVolumeEntry; + let googleDrive: EntryList|null = + getEntry(state, driveRootEntryListKey) as EntryList; + if (!googleDrive) { + googleDrive = new EntryList( + str('DRIVE_DIRECTORY_LABEL'), + VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT); + appendEntry(state, googleDrive); + } + appendChildIfNotExisted(googleDrive, myDrive!); + + // We want the order to be + // - My Drive + // - Shared Drives (if the user has any) + // - Computers (if the user has any) + // - Shared with me + // - Offline + const {sharedDriveDisplayRoot, computersDisplayRoot, fakeEntries} = + volumeInfo; + // Add "Shared drives" (team drives) grand root into Drive. It's guaranteed + // to be resolved at this moment because ADD_VOLUME action will only be + // triggered after resolving all roots. + if (sharedDriveDisplayRoot) { + appendEntry(state, sharedDriveDisplayRoot); + appendChildIfNotExisted(googleDrive, sharedDriveDisplayRoot); + } + + // Add "Computer" grand root into Drive. It's guaranteed to be resolved at + // this moment because ADD_VOLUME action will only be triggered after + // resolving all roots. + if (computersDisplayRoot) { + appendEntry(state, computersDisplayRoot); + appendChildIfNotExisted(googleDrive, computersDisplayRoot); + } + + // Add "Shared with me" into Drive. + const fakeSharedWithMe = + fakeEntries[VolumeManagerCommon.RootType.DRIVE_SHARED_WITH_ME]; + if (fakeSharedWithMe) { + appendEntry(state, fakeSharedWithMe); + state.uiEntries.push(fakeSharedWithMe.toURL()); + appendChildIfNotExisted(googleDrive, fakeSharedWithMe); + } + + // Add "Offline" into Drive. + const fakeOffline = fakeEntries[VolumeManagerCommon.RootType.DRIVE_OFFLINE]; + if (fakeOffline) { + appendEntry(state, fakeOffline); + state.uiEntries.push(fakeOffline.toURL()); + appendChildIfNotExisted(googleDrive, fakeOffline); + } + } + + if (volumeInfo.volumeType === VolumeType.REMOVABLE) { + // It should be nested/grouped when there is more than 1 partition in the + // same device. + const groupingKey = removableGroupKey(volumeMetadata); + const shouldGroup = Object.values(state.volumes).some(v => { + return ( + v.volumeType === VolumeType.REMOVABLE && + removableGroupKey(v) === groupingKey && + v.volumeId != volumeInfo.volumeId); + }); + + if (shouldGroup) { + const parentKey = makeRemovableParentKey(volumeMetadata); + let parentEntry = getEntry(state, parentKey) as EntryList | null; + if (!parentEntry) { + parentEntry = new EntryList( + volumeMetadata.driveLabel || '', + VolumeManagerCommon.RootType.REMOVABLE, volumeMetadata.devicePath); + appendEntry(state, parentEntry); + // Removable devices with group, its parent should always be ejectable. + state.allEntries[parentKey].isEjectable = true; + } + // Update the siblings too. + for (const v of Object.values(state.volumes)) { + // Ignore the partitions that already is nested via `prefixKey`. Note: + // `prefixKey` field is handled by AddVolume() reducer. + if (v.volumeType === VolumeType.REMOVABLE && + removableGroupKey(v) === groupingKey && !v.prefixKey) { + const fileData = getFileData(state, v.rootKey!); + if (fileData?.entry) { + appendChildIfNotExisted(parentEntry, fileData.entry); + // For sub-partition from a removable volume, its children icon + // should be UNKNOWN_REMOVABLE. + state.allEntries[v.rootKey!] = { + ...fileData, + icon: constants.ICON_TYPES.UNKNOWN_REMOVABLE, + }; + } + } + } + // At this point the current `newVolumeEntry` is not in state.volumes, + // we need to add that to that group. + appendChildIfNotExisted(parentEntry, newVolumeEntry); + // For sub-partition from a removable volume, its children icon should be + // UNKNOWN_REMOVABLE. + const fileData = getFileData(state, volumeRootKey); + state.allEntries[volumeRootKey] = { + ...fileData, + icon: constants.ICON_TYPES.UNKNOWN_REMOVABLE, + }; + } + } + + // Update the isEjectable/shouldDelayLoadingChildren field in the FileData. + state.allEntries[volumeRootKey].isEjectable = + (volumeInfo.source === VolumeManagerCommon.Source.DEVICE && + volumeInfo.volumeType !== VolumeManagerCommon.VolumeType.MTP) || + volumeInfo.source === VolumeManagerCommon.Source.FILE; + state.allEntries[volumeRootKey].shouldDelayLoadingChildren = + volumeInfo.source === VolumeManagerCommon.Source.NETWORK && + (volumeInfo.volumeType === VolumeManagerCommon.VolumeType.PROVIDED || + volumeInfo.volumeType === VolumeManagerCommon.VolumeType.SMB); +} + +/** + * Reducer for adding child entries to a parent entry. + */ +export function addChildEntries( + currentState: State, action: AddChildEntriesAction): State { + const {parentKey, entries} = action.payload; + const {allEntries} = currentState; + // The corresponding parent entry item has been removed somehow, do nothing. + if (!allEntries[parentKey]) { + return currentState; + } + + const newEntryKeys = entries.map(entry => entry.toURL()); + // Add children to the parent entry item. + const parentFileData: FileData = { + ...allEntries[parentKey], + children: newEntryKeys, + }; + // We mark all the children's shouldDelayLoadingChildren if the parent entry + // has been delayed. + if (parentFileData.shouldDelayLoadingChildren) { + for (const entryKey of newEntryKeys) { + allEntries[entryKey] = { + ...allEntries[entryKey], + shouldDelayLoadingChildren: true, + }; + } + } + + return { + ...currentState, + allEntries: { + ...allEntries, + [parentKey]: parentFileData, + }, + }; +} diff --git a/ui/file_manager/file_manager/state/reducers/all_entries_unittest.ts b/ui/file_manager/file_manager/state/reducers/all_entries_unittest.ts index 873e95a2a871d..6364ebc6b7010 100644 --- a/ui/file_manager/file_manager/state/reducers/all_entries_unittest.ts +++ b/ui/file_manager/file_manager/state/reducers/all_entries_unittest.ts @@ -2,48 +2,37 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js'; +import {assertArrayEquals, assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js'; import {MockVolumeManager} from '../../background/js/mock_volume_manager.js'; -import {DialogType} from '../../common/js/dialog_type.js'; -import {FakeEntryImpl, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {EntryList, FakeEntryImpl, VolumeEntry} from '../../common/js/files_app_entry_types.js'; import {MockFileSystem} from '../../common/js/mock_entry.js'; import {waitUntil} from '../../common/js/test_error_reporting.js'; import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; -import {Crostini} from '../../externs/background/crostini.js'; import {EntryType, FileData} from '../../externs/ts/state.js'; -import {FileSelectionHandler} from '../../foreground/js/file_selection.js'; +import {VolumeInfo} from '../../externs/volume_info.js'; +import {constants} from '../../foreground/js/constants.js'; import {MetadataItem} from '../../foreground/js/metadata/metadata_item.js'; -import {MetadataModel} from '../../foreground/js/metadata/metadata_model.js'; import {MockMetadataModel} from '../../foreground/js/metadata/mock_metadata.js'; -import {TaskController} from '../../foreground/js/task_controller.js'; import {ActionType} from '../actions.js'; -import {ClearStaleCachedEntriesAction} from '../actions/all_entries.js'; -import {allEntriesSize, assertAllEntriesEqual, cd, changeSelection, updMetadata} from '../for_tests.js'; -import {getEmptyState, getStore, Store} from '../store.js'; +import {addChildEntries as addChildEntriesAction, ClearStaleCachedEntriesAction} from '../actions/all_entries.js'; +import {addVolume} from '../actions/volumes.js'; +import {allEntriesSize, assertAllEntriesEqual, cd, changeSelection, createFakeFileData, createFakeVolume, createFakeVolumeMetadata, setUpFileManagerOnWindow, setupStore, updMetadata} from '../for_tests.js'; +import {getEmptyState, Store} from '../store.js'; -import {clearCachedEntries} from './all_entries.js'; +import {addChildEntries, cacheEntries, clearCachedEntries, getMyFiles} from './all_entries.js'; +import {driveRootEntryListKey, makeRemovableParentKey, myFilesEntryListKey} from './volumes.js'; let store: Store; let fileSystem: MockFileSystem; export function setUp() { - store = getStore(); - // changeDirectory() reducer uses the VolumeManager. - const volumeManager = new MockVolumeManager(); - window.fileManager = { - volumeManager: volumeManager, - metadataModel: new MockMetadataModel({}) as unknown as MetadataModel, - crostini: {} as unknown as Crostini, - selectionHandler: {} as unknown as FileSelectionHandler, - taskController: {} as unknown as TaskController, - dialogType: DialogType.FULL_PAGE, - }; + setUpFileManagerOnWindow(); - store.init(getEmptyState()); + store = setupStore(); - fileSystem = volumeManager + fileSystem = window.fileManager.volumeManager .getCurrentProfileVolumeInfo( VolumeManagerCommon.VolumeType.DOWNLOADS)!.fileSystem as MockFileSystem; @@ -56,6 +45,30 @@ export function setUp() { ]); } +/** Generate MyFiles entry with fake entry list. */ +function createMyFilesDataWithEntryList(): FileData { + return createFakeFileData({ + entry: new EntryList('My files', VolumeManagerCommon.RootType.MY_FILES), + label: 'My files', + type: EntryType.ENTRY_LIST, + }); +} + +/** Generate MyFiles entry with real volume entry. */ +function createMyFilesDataWithVolumeEntry(): + {fileData: FileData, volumeInfo: VolumeInfo} { + const {volumeManager} = window.fileManager; + const downloadsVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const fileData = createFakeFileData({ + entry: new VolumeEntry(downloadsVolumeInfo), + volumeType: VolumeManagerCommon.VolumeType.DOWNLOADS, + label: 'My files', + type: EntryType.VOLUME_ROOT, + }); + return {fileData, volumeInfo: downloadsVolumeInfo}; +} + /** Tests that entries get cached in the allEntries. */ export function testAllEntries() { assertEquals( @@ -211,3 +224,343 @@ export function testUpdateMetadata() { assertFalse(!!resultEntry.metadata.isRestrictedForDestination); assertTrue(!!resultEntry.metadata.isDlpRestricted); } + +/** + * Tests that getMyFiles will return the entry list if the volume is not in the + * store. + */ +export function testGetMyFilesWithFakeEntryList() { + const currentState = getEmptyState(); + // Add fake entry list MyFiles to the store. + const myFilesEntryList = createMyFilesDataWithEntryList(); + currentState.allEntries[myFilesEntryListKey] = myFilesEntryList; + const {myFilesEntry, myFilesVolume} = getMyFiles(currentState); + // Expect MyFiles entry list returned, no volume. + assertEquals(myFilesEntryList.entry, myFilesEntry); + assertEquals(null, myFilesVolume); +} + +/** + * Tests that getMyFiles will return the volume entry if the volume is already + * in the store. + */ +export function testGetMyFilesWithVolumeEntry() { + const currentState = getEmptyState(); + // Add MyFiles volume to the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const volume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = volume; + const {myFilesEntry, myFilesVolume} = getMyFiles(currentState); + // Expect MyFiles volume entry and volume returned. + assertEquals(fileData.entry, myFilesEntry); + assertEquals(volume, myFilesVolume); +} + +/** + * Tests that getMyFiles will create a entry list if no MyFiles entry + * in the store. + */ +export function testGetMyFilesCreateEntryList() { + const currentState = getEmptyState(); + const {myFilesEntry, myFilesVolume} = getMyFiles(currentState); + // Expect entry list is created in place. + const myFilesFileData: FileData = + currentState.allEntries[myFilesEntryListKey]; + assertNotEquals(undefined, myFilesFileData); + const myFIlesEntryList = myFilesFileData.entry as EntryList; + assertEquals( + VolumeManagerCommon.RootType.MY_FILES, myFIlesEntryList.rootType); + assertEquals(myFIlesEntryList, myFilesEntry); + assertEquals(null, myFilesVolume); +} + +/** Tests that MyFiles volume entry can be cached correctly. */ +export function testCacheEntriesForMyFilesVolume() { + const currentState = getEmptyState(); + const myFilesFileData = createMyFilesDataWithEntryList(); + const myFilesEntryList = myFilesFileData.entry as EntryList; + // Put MyFiles entry in the store and add ui entries as its children. + currentState.allEntries[myFilesEntryList.toURL()] = myFilesFileData; + const playFilesEntry = new FakeEntryImpl( + 'Play files', VolumeManagerCommon.RootType.ANDROID_FILES); + myFilesEntryList.addEntry(playFilesEntry); + const linuxFilesEntry = + new FakeEntryImpl('Linux files', VolumeManagerCommon.RootType.CROSTINI); + myFilesEntryList.addEntry(linuxFilesEntry); + + const {volumeManager} = window.fileManager; + const myFilesVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const myFilesVolumeMetadata = createFakeVolumeMetadata({ + volumeId: myFilesVolumeInfo.volumeId, + volumeType: myFilesVolumeInfo.volumeType, + }); + cacheEntries(currentState, addVolume({ + volumeInfo: myFilesVolumeInfo, + volumeMetadata: myFilesVolumeMetadata, + })); + + // cacheEntries() updates state in place. + const newState = currentState; + // Expect all existing ui children will be added to the real MyFiles entry. + const myFilesVolumeEntry: VolumeEntry = + newState.allEntries[myFilesVolumeInfo.displayRoot!.toURL()].entry; + const uiChildren = myFilesVolumeEntry.getUIChildren(); + assertEquals(2, uiChildren.length); + assertEquals(playFilesEntry, uiChildren[0]); + assertEquals(linuxFilesEntry, uiChildren[1]); + assertEquals(0, myFilesEntryList.getUIChildren().length); +} + +/** Tests that volume nested in MyFiles volume can be cached correctly. */ +export function testCacheEntriesForNestedVolumeInMyFilesVolume() { + const currentState = getEmptyState(); + // Put MyFiles and play files ui entry in the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesVolumeEntry = fileData.entry as VolumeEntry; + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + // Placeholder ui entry and the volume entry it represents have the same + // label. + const label = 'Play files'; + const playFilesUiEntry = + new FakeEntryImpl(label, VolumeManagerCommon.RootType.ANDROID_FILES); + myFilesVolumeEntry.addEntry(playFilesUiEntry); + fileData.children.push(playFilesUiEntry.toURL()); + + const {volumeManager} = window.fileManager; + const playFilesVolumeInfo = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.ANDROID_FILES, 'playFilesId', label); + volumeManager.volumeInfoList.add(playFilesVolumeInfo); + const playFilesVolumeMetadata = createFakeVolumeMetadata({ + volumeType: playFilesVolumeInfo.volumeType, + volumeId: playFilesVolumeInfo.volumeId, + }); + cacheEntries(currentState, addVolume({ + volumeInfo: playFilesVolumeInfo, + volumeMetadata: playFilesVolumeMetadata, + })); + // cacheEntries() updates state in place. + const newState = currentState; + // Expect the new play file volume will be nested inside MyFiles and the old + // placeholder will be removed. + const playFilesVolumeEntry = + newState.allEntries[playFilesVolumeInfo.displayRoot!.toURL()].entry; + const newMyFilesFileData: FileData = + newState.allEntries[myFilesVolumeEntry.toURL()]; + assertEquals(1, myFilesVolumeEntry.getUIChildren().length); + assertEquals(playFilesVolumeEntry, myFilesVolumeEntry.getUIChildren()[0]); + assertEquals(1, newMyFilesFileData.children.length); + assertEquals(playFilesVolumeEntry.toURL(), newMyFilesFileData.children[0]); +} + +/** Tests that drive volume can be cached correctly. */ +export function testAddDriveVolume(done: () => void) { + const currentState = getEmptyState(); + + const {volumeManager} = window.fileManager; + const driveVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DRIVE)!; + const driveVolumeMetadata = createFakeVolumeMetadata({ + volumeType: driveVolumeInfo.volumeType, + volumeId: driveVolumeInfo.volumeId, + }); + // DriveFS takes time to resolve. + driveVolumeInfo.resolveDisplayRoot(() => { + cacheEntries(currentState, addVolume({ + volumeInfo: driveVolumeInfo, + volumeMetadata: driveVolumeMetadata, + })); + // cacheEntries() updates state in place. + const newState = currentState; + // Expect all fake entries inside Drive will be added as its children. + const driveFakeRootEntry: EntryList = + newState.allEntries[driveRootEntryListKey].entry; + assertEquals( + VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT, + driveFakeRootEntry.rootType); + const driveChildren = driveFakeRootEntry.getUIChildren(); + assertEquals(5, driveChildren.length); + // My Drive. + const myDriveEntry: VolumeEntry = + newState.allEntries[driveChildren[0]!.toURL()].entry; + assertEquals(myDriveEntry, driveChildren[0]); + // Shared drives root. + const sharedDrivesRootEntry: DirectoryEntry = + newState.allEntries[driveChildren[1]!.toURL()].entry; + assertEquals('/team_drives', sharedDrivesRootEntry.fullPath); + assertEquals(sharedDrivesRootEntry, driveChildren[1]); + // Computers root. + const computersRootEntry: DirectoryEntry = + newState.allEntries[driveChildren[2]!.toURL()].entry; + assertEquals('/Computers', computersRootEntry.fullPath); + assertEquals(computersRootEntry, driveChildren[2]); + // Shared with me. + const sharedWithMeEntry: FakeEntryImpl = + newState.allEntries[driveChildren[3]!.toURL()].entry; + assertEquals( + VolumeManagerCommon.RootType.DRIVE_SHARED_WITH_ME, + sharedWithMeEntry.rootType); + assertEquals(sharedWithMeEntry, driveChildren[3]); + // Offline. + const offlineEntry: FakeEntryImpl = + newState.allEntries[driveChildren[4]!.toURL()].entry; + assertEquals(offlineEntry, driveChildren[4]); + assertEquals( + VolumeManagerCommon.RootType.DRIVE_OFFLINE, offlineEntry.rootType); + assertArrayEquals( + [sharedWithMeEntry.toURL(), offlineEntry.toURL()], newState.uiEntries); + + done(); + }); +} + +/** Tests that multiple partition volumes can be cached correctly. */ +export function testCacheEntriesForMultipleUsbPartitionsGrouping() { + const currentState = getEmptyState(); + // Add partition-1 into the store. + const {volumeManager} = window.fileManager; + const partition1VolumeInfo = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:partition1', + 'Partition 1', '/device/path/1'); + volumeManager.volumeInfoList.add(partition1VolumeInfo); + const partition1VolumeEntry = new VolumeEntry(partition1VolumeInfo); + const partition1FileData = createFakeFileData({ + entry: partition1VolumeEntry, + label: partition1VolumeInfo.label, + type: EntryType.VOLUME_ROOT, + }); + const partition1Volume = createFakeVolume({ + volumeId: partition1VolumeInfo.volumeId, + volumeType: VolumeManagerCommon.VolumeType.REMOVABLE, + rootKey: partition1VolumeInfo.displayRoot!.toURL(), + label: partition1VolumeInfo.label, + devicePath: partition1VolumeInfo.devicePath, + driveLabel: 'USB_Drive', + }); + currentState.volumes[partition1Volume.volumeId] = partition1Volume; + currentState.allEntries[partition1VolumeEntry.toURL()] = partition1FileData; + + const partition2VolumeInfo = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:partition2', + 'Partition 2', partition1Volume.devicePath); + volumeManager.volumeInfoList.add(partition2VolumeInfo); + const partition2VolumeMetadata = createFakeVolumeMetadata({ + volumeType: partition2VolumeInfo.volumeType, + volumeId: partition2VolumeInfo.volumeId, + devicePath: partition1Volume.devicePath, + driveLabel: partition1Volume.driveLabel, + }); + cacheEntries(currentState, addVolume({ + volumeInfo: partition2VolumeInfo, + volumeMetadata: partition2VolumeMetadata, + })); + // cacheEntries() updates state in place. + const newState = currentState; + // Expect a fake parent entry list will be created. + const parentEntryFileData: FileData = + newState.allEntries[makeRemovableParentKey(partition1Volume)]; + const parentEntry = parentEntryFileData.entry as EntryList; + assertEquals('USB_Drive', parentEntry.label); + assertEquals(VolumeManagerCommon.RootType.REMOVABLE, parentEntry.rootType); + assertTrue(parentEntryFileData.isEjectable); + // Expect both partition1 and partition2 will be added as children. + const partition2VolumeEntry: VolumeEntry = + newState.allEntries[partition2VolumeInfo.displayRoot!.toURL()].entry; + assertEquals(2, parentEntry.getUIChildren().length); + assertEquals(partition1VolumeEntry, parentEntry.getUIChildren()[0]); + assertEquals(partition2VolumeEntry, parentEntry.getUIChildren()[1]); + assertEquals( + constants.ICON_TYPES.UNKNOWN_REMOVABLE, + newState.allEntries[partition1VolumeEntry.toURL()].icon); + assertEquals( + constants.ICON_TYPES.UNKNOWN_REMOVABLE, + newState.allEntries[partition2VolumeEntry.toURL()].icon); +} + +/** Tests that child entries can be added to the store correctly. */ +export function testAddChildEntries() { + const currentState = getEmptyState(); + + // Add parent/children entries to the store. + const fakeFs = new MockFileSystem('fake-fs'); + fakeFs.populate([ + '/aaa/', + '/aaa/1/', + '/aaa/2/', + '/aaa/2/123/', + ]); + currentState.allEntries[fakeFs.entries['/aaa'].toURL()] = createFakeFileData({ + entry: fakeFs.entries['/aaa'], + label: 'AAA', + type: EntryType.FS_API, + }); + currentState.allEntries[fakeFs.entries['/aaa/1'].toURL()] = + createFakeFileData({ + entry: fakeFs.entries['/aaa/1'], + label: 'AAA 1', + type: EntryType.FS_API, + }); + currentState.allEntries[fakeFs.entries['/aaa/2'].toURL()] = + createFakeFileData({ + entry: fakeFs.entries['/aaa/2'], + label: 'AAA 2', + type: EntryType.FS_API, + shouldDelayLoadingChildren: true, + }); + currentState.allEntries[fakeFs.entries['/aaa/2/123'].toURL()] = + createFakeFileData({ + entry: fakeFs.entries['/aaa/2/123'], + label: 'AAA 123', + type: EntryType.FS_API, + }); + + // Add child entries for /aaa/. + const newState1 = addChildEntries( + currentState, addChildEntriesAction({ + parentKey: fakeFs.entries['/aaa'].toURL(), + entries: [fakeFs.entries['/aaa/1'], fakeFs.entries['/aaa/2']], + })); + // Expect the children filed is updated. + const newChildren1 = + newState1.allEntries[fakeFs.entries['/aaa'].toURL()].children; + assertEquals(2, newChildren1.length); + assertEquals(fakeFs.entries['/aaa/1'].toURL(), newChildren1[0]); + assertEquals(fakeFs.entries['/aaa/2'].toURL(), newChildren1[1]); + + // Add child entries for /aaa/2 who has shouldDelayLoadingChildren. + assertFalse(currentState.allEntries[fakeFs.entries['/aaa/2/123'].toURL()] + .shouldDelayLoadingChildren); + const newState2 = + addChildEntries(currentState, addChildEntriesAction({ + parentKey: fakeFs.entries['/aaa/2'].toURL(), + entries: [fakeFs.entries['/aaa/2/123']], + })); + // Expect child entry also has shouldDelayLoadingChildren=true. + const newChildren2 = + newState2.allEntries[fakeFs.entries['/aaa/2'].toURL()].children; + assertEquals(1, newChildren2.length); + assertEquals(fakeFs.entries['/aaa/2/123'].toURL(), newChildren2[0]); + assertTrue(newState2.allEntries[fakeFs.entries['/aaa/2/123'].toURL()] + .shouldDelayLoadingChildren); + + // Add child entries for non-existed parent. + const newState3 = addChildEntries(currentState, addChildEntriesAction({ + parentKey: 'non-exist-key', + entries: [fakeFs.entries['/aaa/1']], + })); + // Expect nothing happens. + assertEquals(currentState, newState3); +} diff --git a/ui/file_manager/file_manager/state/reducers/android_apps.ts b/ui/file_manager/file_manager/state/reducers/android_apps.ts new file mode 100644 index 0000000000000..439fccf3f4a40 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/android_apps.ts @@ -0,0 +1,27 @@ +// 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. + +/** + * @fileoverview Reducer for android apps. + * + * This file is checked via TS, so we suppress Closure checks. + * @suppress {checkTypes} + */ + +import {State} from '../../externs/ts/state.js'; +import {AddAndroidAppsAction} from '../actions/android_apps.js'; + +export function addAndroidApps( + currentState: State, action: AddAndroidAppsAction): State { + const {apps} = action.payload; + + const androidApps: Record = {}; + for (const app of apps) { + androidApps[app.packageName] = app; + } + return { + ...currentState, + androidApps, + }; +} diff --git a/ui/file_manager/file_manager/state/reducers/android_apps_unittest.ts b/ui/file_manager/file_manager/state/reducers/android_apps_unittest.ts new file mode 100644 index 0000000000000..8b44457b6503c --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/android_apps_unittest.ts @@ -0,0 +1,40 @@ +// 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 {assertEquals} from 'chrome://webui-test/chai_assert.js'; + +import {addAndroidApps as addAndroidAppsAction} from '../actions/android_apps.js'; +import {getEmptyState} from '../store.js'; + +import {addAndroidApps} from './android_apps.js'; + +/** Tests that android apps can be added correctly to the store. */ +export function testAddAndroidApps() { + const currentState = getEmptyState(); + const androidApps: chrome.fileManagerPrivate.AndroidApp[] = [ + { + name: 'App 1', + packageName: 'com.test.app1', + activityName: 'Activity1', + iconSet: {icon16x16Url: 'url1', icon32x32Url: 'url2'}, + }, + { + name: 'App 2', + packageName: 'com.test.app2', + activityName: 'Activity2', + iconSet: {icon16x16Url: 'url3', icon32x32Url: 'url4'}, + }, + ]; + const newState = + addAndroidApps(currentState, addAndroidAppsAction({apps: androidApps})); + const keys = Object.keys(newState.androidApps); + assertEquals(2, keys.length); + assertEquals('com.test.app1', keys[0]); + assertEquals('com.test.app2', keys[1]); + assertEquals('App 1', newState.androidApps[keys[0]!].name); + assertEquals('App 2', newState.androidApps[keys[1]!].name); + assertEquals('Activity1', newState.androidApps[keys[0]!].activityName); + assertEquals('url1', newState.androidApps[keys[0]!].iconSet.icon16x16Url); + assertEquals('url4', newState.androidApps[keys[1]!].iconSet.icon32x32Url); +} diff --git a/ui/file_manager/file_manager/state/reducers/current_directory.ts b/ui/file_manager/file_manager/state/reducers/current_directory.ts index 9457f7f25c641..2b96a84ddc382 100644 --- a/ui/file_manager/file_manager/state/reducers/current_directory.ts +++ b/ui/file_manager/file_manager/state/reducers/current_directory.ts @@ -17,12 +17,14 @@ import {ChangeDirectoryAction, ChangeFileTasksAction, ChangeSelectionAction, Upd */ export function changeDirectory( currentState: State, action: ChangeDirectoryAction): State { - const fileData = currentState.allEntries[action.payload.key]; + const {key, status} = action.payload; + + const fileData = currentState.allEntries[key]; let selection = currentState.currentDirectory?.selection; // Use an empty selection when a selection isn't defined or it's navigating to // a new directory. - if (!selection || currentState.currentDirectory?.key !== action.payload.key) { + if (!selection || currentState.currentDirectory?.key !== key) { selection = { keys: [], dirCount: 0, @@ -43,7 +45,7 @@ export function changeDirectory( currentState.currentDirectory?.hasDlpDisabledFiles || false; // Use empty content when it isn't defined or it's navigating to a new // directory. The content will be updated again after a successful scan. - if (!content || currentState.currentDirectory?.key !== action.payload.key) { + if (!content || currentState.currentDirectory?.key !== key) { content = { keys: [], }; @@ -51,8 +53,8 @@ export function changeDirectory( } let currentDirectory: CurrentDirectory = { - key: action.payload.key, - status: action.payload.status, + key, + status, pathComponents: [], content: content, rootType: undefined, diff --git a/ui/file_manager/file_manager/state/reducers/current_directory_unittest.ts b/ui/file_manager/file_manager/state/reducers/current_directory_unittest.ts index 0f6ef8ff53b14..4a5ff97d3ee27 100644 --- a/ui/file_manager/file_manager/state/reducers/current_directory_unittest.ts +++ b/ui/file_manager/file_manager/state/reducers/current_directory_unittest.ts @@ -4,24 +4,17 @@ import {assertEquals, assertTrue} from 'chrome://webui-test/chai_assert.js'; -import {MockVolumeManager} from '../../background/js/mock_volume_manager.js'; -import {DialogType} from '../../common/js/dialog_type.js'; import {MockFileSystem} from '../../common/js/mock_entry.js'; import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; -import {Crostini} from '../../externs/background/crostini.js'; import {CurrentDirectory, FileTasks, PropStatus} from '../../externs/ts/state.js'; import {FakeFileSelectionHandler} from '../../foreground/js/fake_file_selection_handler.js'; -import {FileSelectionHandler} from '../../foreground/js/file_selection.js'; import {MetadataItem} from '../../foreground/js/metadata/metadata_item.js'; -import {MetadataModel} from '../../foreground/js/metadata/metadata_model.js'; -import {MockMetadataModel} from '../../foreground/js/metadata/mock_metadata.js'; -import {TaskController} from '../../foreground/js/task_controller.js'; import {ActionType} from '../actions.js'; import {ClearStaleCachedEntriesAction} from '../actions/all_entries.js'; import {changeDirectory, updateSelection} from '../actions/current_directory.js'; import {fetchFileTasks} from '../actions_producers/current_directory.js'; -import {allEntriesSize, assertAllEntriesEqual, assertStateEquals, updateContent, updMetadata, waitDeepEquals} from '../for_tests.js'; -import {getEmptyState, getFilesData, getStore, Store} from '../store.js'; +import {allEntriesSize, assertAllEntriesEqual, assertStateEquals, setUpFileManagerOnWindow, setupStore, updateContent, updMetadata, waitDeepEquals} from '../for_tests.js'; +import {getFilesData, Store} from '../store.js'; import {clearCachedEntries} from './all_entries.js'; @@ -30,19 +23,13 @@ let fileSystem: MockFileSystem; export function setUp() { // changeDirectory() reducer uses the VolumeManager. - const volumeManager = new MockVolumeManager(); - window.fileManager = { - volumeManager: volumeManager, - metadataModel: new MockMetadataModel({}) as unknown as MetadataModel, - crostini: {} as unknown as Crostini, - selectionHandler: new FakeFileSelectionHandler() as unknown as - FileSelectionHandler, - taskController: {} as unknown as TaskController, - dialogType: DialogType.FULL_PAGE, - }; + setUpFileManagerOnWindow(); + window.fileManager.selectionHandler = new FakeFileSelectionHandler(); - fileSystem = volumeManager.getCurrentProfileVolumeInfo( - 'downloads')!.fileSystem as MockFileSystem; + fileSystem = + window.fileManager.volumeManager.getCurrentProfileVolumeInfo( + 'downloads')!.fileSystem as + MockFileSystem; fileSystem.populate([ '/dir-1/', '/dir-2/sub-dir/', @@ -51,12 +38,6 @@ export function setUp() { ]); } -function setupStore(): Store { - const store = getStore(); - store.init(getEmptyState()); - return store; -} - function cd(store: Store, directory: DirectoryEntry) { store.dispatch(changeDirectory( {to: directory, toKey: directory.toURL(), status: PropStatus.SUCCESS})); diff --git a/ui/file_manager/file_manager/state/reducers/folder_shortcuts.ts b/ui/file_manager/file_manager/state/reducers/folder_shortcuts.ts new file mode 100644 index 0000000000000..c70948cea7e01 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/folder_shortcuts.ts @@ -0,0 +1,69 @@ +// 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 {util} from '../../common/js/util.js'; +import {State} from '../../externs/ts/state.js'; +import {AddFolderShortcutAction, RefreshFolderShortcutAction, RemoveFolderShortcutAction} from '../actions/folder_shortcuts.js'; +import {getEntry} from '../store.js'; + +export function refreshFolderShortcut( + currentState: State, action: RefreshFolderShortcutAction): State { + const {entries} = action.payload; + + return { + ...currentState, + folderShortcuts: entries.map(entry => entry.toURL()), + }; +} + +export function addFolderShortcut( + currentState: State, action: AddFolderShortcutAction): State { + const {entry} = action.payload; + const key = entry.toURL(); + const {folderShortcuts} = currentState; + + for (let i = 0; i < folderShortcuts.length; i++) { + // Do nothing if the key is already existed. + if (key === folderShortcuts[i]) { + return currentState; + } + + const shortcutEntry = getEntry(currentState, folderShortcuts[i]!); + // The folder shortcut array is sorted, the new item will be added just + // before the first larger item. + if (util.comparePath(shortcutEntry!, entry) > 0) { + return { + ...currentState, + folderShortcuts: [ + ...folderShortcuts.slice(0, i), + key, + ...folderShortcuts.slice(i), + ], + }; + } + } + + // If for loop is not returned, the key is not added yet, add it at the last. + return { + ...currentState, + folderShortcuts: folderShortcuts.concat(key), + }; +} + +export function removeFolderShortcut( + currentState: State, action: RemoveFolderShortcutAction): State { + const {key} = action.payload; + const {folderShortcuts} = currentState; + const isExisted = folderShortcuts.find(k => k === key); + // Do nothing if the key is not existed. + if (!isExisted) { + return currentState; + } + + + return { + ...currentState, + folderShortcuts: folderShortcuts.filter(k => k !== key), + }; +} diff --git a/ui/file_manager/file_manager/state/reducers/folder_shortcuts_unittest.ts b/ui/file_manager/file_manager/state/reducers/folder_shortcuts_unittest.ts new file mode 100644 index 0000000000000..cc2c6527d3b26 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/folder_shortcuts_unittest.ts @@ -0,0 +1,116 @@ +// 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 {assertEquals} from 'chrome://webui-test/chai_assert.js'; + +import {MockFileSystem} from '../../common/js/mock_entry.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {EntryType} from '../../externs/ts/state.js'; +import {addFolderShortcut as addFolderShortcutAction, refreshFolderShortcut as refreshFolderShortcutAction, removeFolderShortcut as removeFolderShortcutAction} from '../actions/folder_shortcuts.js'; +import {createFakeFileData} from '../for_tests.js'; +import {getEmptyState} from '../store.js'; + +import {addFolderShortcut, refreshFolderShortcut, removeFolderShortcut} from './folder_shortcuts.js'; + +/** Generate a fake file system with fake file entries. */ +function setupFileSystem(): MockFileSystem { + const fileSystem = new MockFileSystem('fake-fs'); + fileSystem.populate([ + '/shortcut-1/', + '/shortcut-2/', + '/shortcut-3/', + '/shortcut-4/', + ]); + return fileSystem; +} + + +/** Tests folder shortcuts can be refreshed correctly. */ +export function testRefreshFolderShortcuts() { + const currentState = getEmptyState(); + // Add shortcut-1 to the store. + const fileSystem = setupFileSystem(); + const shortcutEntry1: DirectoryEntry = fileSystem.entries['/shortcut-1']; + const shortcutEntry2: DirectoryEntry = fileSystem.entries['/shortcut-2']; + const shortcutEntry3: DirectoryEntry = fileSystem.entries['/shortcut-3']; + currentState.allEntries[shortcutEntry1.toURL()] = createFakeFileData({ + entry: shortcutEntry1, + volumeType: VolumeManagerCommon.VolumeType.DRIVE, + label: 'shortcut 1', + type: EntryType.FS_API, + }); + currentState.folderShortcuts.push(shortcutEntry1.toURL()); + + const newState = refreshFolderShortcut( + currentState, + refreshFolderShortcutAction({entries: [shortcutEntry2, shortcutEntry3]})); + assertEquals(2, newState.folderShortcuts.length); + assertEquals(shortcutEntry2.toURL(), newState.folderShortcuts[0]); + assertEquals(shortcutEntry3.toURL(), newState.folderShortcuts[1]); +} + +/** Tests folder shortcut can be added correctly. */ +export function testAddFolderShortcut() { + const currentState = getEmptyState(); + // Add shortcut-1 and shortcut-3 to the store. + const fileSystem = setupFileSystem(); + const shortcutEntry1: DirectoryEntry = fileSystem.entries['/shortcut-1']; + const shortcutEntry2: DirectoryEntry = fileSystem.entries['/shortcut-2']; + const shortcutEntry3: DirectoryEntry = fileSystem.entries['/shortcut-3']; + const shortcutEntry4: DirectoryEntry = fileSystem.entries['/shortcut-4']; + currentState.allEntries[shortcutEntry1.toURL()] = createFakeFileData({ + entry: shortcutEntry1, + volumeType: VolumeManagerCommon.VolumeType.DRIVE, + label: 'shortcut 1', + type: EntryType.FS_API, + }); + currentState.allEntries[shortcutEntry3.toURL()] = createFakeFileData({ + entry: shortcutEntry3, + volumeType: VolumeManagerCommon.VolumeType.DRIVE, + label: 'shortcut 3', + type: EntryType.FS_API, + }); + currentState.folderShortcuts.push(shortcutEntry1.toURL()); + currentState.folderShortcuts.push(shortcutEntry3.toURL()); + + // Add a new shortcut. + const newState1 = addFolderShortcut( + currentState, addFolderShortcutAction({entry: shortcutEntry2})); + assertEquals(3, newState1.folderShortcuts.length); + assertEquals(shortcutEntry2.toURL(), newState1.folderShortcuts[1]); + // Add an already existed shortcut. + const newState2 = addFolderShortcut( + currentState, addFolderShortcutAction({entry: shortcutEntry1})); + assertEquals(newState2.folderShortcuts, currentState.folderShortcuts); + // Add another entry to check sorting. + const newState3 = addFolderShortcut( + currentState, addFolderShortcutAction({entry: shortcutEntry4})); + assertEquals(3, newState3.folderShortcuts.length); + assertEquals(shortcutEntry4.toURL(), newState3.folderShortcuts[2]); +} + +/** Tests folder shortcut can be removed correctly. */ +export function testRemoveFolderShortcut() { + const currentState = getEmptyState(); + // Add shortcut-1 to the store. + const fileSystem = setupFileSystem(); + const shortcutEntry1: DirectoryEntry = fileSystem.entries['/shortcut-1']; + const shortcutEntry2: DirectoryEntry = fileSystem.entries['/shortcut-2']; + currentState.allEntries[shortcutEntry1.toURL()] = createFakeFileData({ + entry: shortcutEntry1, + volumeType: VolumeManagerCommon.VolumeType.DRIVE, + label: 'shortcut 1', + type: EntryType.FS_API, + }); + currentState.folderShortcuts.push(shortcutEntry1.toURL()); + + // Remove a shortcut. + const newState1 = removeFolderShortcut( + currentState, removeFolderShortcutAction({key: shortcutEntry1.toURL()})); + assertEquals(0, newState1.folderShortcuts.length); + // Remove a non-exist shortcut. + const newState2 = removeFolderShortcut( + currentState, removeFolderShortcutAction({key: shortcutEntry2.toURL()})); + assertEquals(newState2.folderShortcuts, currentState.folderShortcuts); +} diff --git a/ui/file_manager/file_manager/state/reducers/navigation.ts b/ui/file_manager/file_manager/state/reducers/navigation.ts new file mode 100644 index 0000000000000..2e8aca77fbd03 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/navigation.ts @@ -0,0 +1,238 @@ +// 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 {EntryList, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {FilesAppEntry} from '../../externs/files_app_entry_interfaces.js'; +import {NavigationKey, NavigationRoot, NavigationSection, NavigationType, State, Volume, VolumeId} from '../../externs/ts/state.js'; +import {RefreshNavigationRootsAction, UpdateNavigationEntryAction} from '../actions/navigation.js'; +import {getEntry, getFileData} from '../store.js'; + +import {getMyFiles} from './all_entries.js'; +import {driveRootEntryListKey, recentRootKey, trashRootKey} from './volumes.js'; + +const VolumeType = VolumeManagerCommon.VolumeType; + +const sections = new Map(); +// My Files. +sections.set(VolumeType.DOWNLOADS, NavigationSection.MY_FILES); +// Cloud. +sections.set(VolumeType.DRIVE, NavigationSection.CLOUD); +sections.set(VolumeType.SMB, NavigationSection.CLOUD); +sections.set(VolumeType.PROVIDED, NavigationSection.CLOUD); +sections.set(VolumeType.DOCUMENTS_PROVIDER, NavigationSection.CLOUD); +// Removable. +sections.set(VolumeType.REMOVABLE, NavigationSection.REMOVABLE); +sections.set(VolumeType.MTP, NavigationSection.REMOVABLE); +sections.set(VolumeType.ARCHIVE, NavigationSection.REMOVABLE); + +/** Returns the entry for the volume's top-most prefix or the volume itself. */ +function getPrefixEntryOrEntry(state: State, volume: Volume): VolumeEntry| + EntryList|null { + if (volume.prefixKey) { + const entry = getEntry(state, volume.prefixKey); + return entry as VolumeEntry | EntryList | null; + } + if (volume.volumeType === VolumeType.DOWNLOADS) { + return getMyFiles(state).myFilesEntry; + } + + const entry = getEntry(state, volume.rootKey!); + return entry as VolumeEntry | EntryList | null; +} + +/** + * Reducer for refresh navigation roots, it will construct the + * navigation roots with Entries/Volume in desired order: + * 1. Recents. + * 2. Shortcuts. + * 3. "My-Files" (grouping), actually Downloads volume. + * 4. Drive volumes. + * 5. Trash. + * 6. Other FSP (File System Provider) (when mounted). + * 7. Other volumes (MTP, ARCHIVE, REMOVABLE). + * 8. Android apps. + */ +export function refreshNavigationRoots( + currentState: State, _action: RefreshNavigationRootsAction): State { + const { + navigation: {roots: previousRoots}, + folderShortcuts, + androidApps, + } = currentState; + + /** Roots in the desired order. */ + const roots: NavigationRoot[] = []; + /** Set to avoid adding the same entry multiple times. */ + const processedEntryKeys = new Set(); + + // 1. Add the Recent/Materialized view root. + const recentRoot = previousRoots.find(root => root.key === recentRootKey); + if (recentRoot) { + roots.push(recentRoot); + processedEntryKeys.add(recentRootKey); + } else { + const recentEntry = + getEntry(currentState, recentRootKey) as FilesAppEntry | null; + if (recentEntry) { + roots.push({ + key: recentRootKey, + section: NavigationSection.TOP, + separator: false, + type: NavigationType.RECENT, + }); + processedEntryKeys.add(recentRootKey); + } + } + + // 2. Add the Shortcuts. + // TODO: Since Shortcuts are only for Drive, do we need to remove shortcuts + // if Drive isn't available anymore? + folderShortcuts.forEach(shortcutKey => { + const shortcutEntry = + getEntry(currentState, shortcutKey) as FilesAppEntry | null; + if (shortcutEntry) { + roots.push({ + key: shortcutKey, + section: NavigationSection.TOP, + separator: false, + type: NavigationType.SHORTCUT, + }); + processedEntryKeys.add(shortcutKey); + } + }); + + // 3. MyFiles + const {myFilesEntry, myFilesVolume} = getMyFiles(currentState); + roots.push({ + key: myFilesEntry.toURL(), + section: NavigationSection.MY_FILES, + separator: true, + type: myFilesVolume ? NavigationType.VOLUME : NavigationType.ENTRY_LIST, + }); + processedEntryKeys.add(myFilesEntry.toURL()); + + // 4. Drive. + const driveEntry = + getEntry(currentState, driveRootEntryListKey) as EntryList | null; + if (driveEntry) { + roots.push({ + key: driveEntry.toURL(), + section: NavigationSection.CLOUD, + separator: true, + type: NavigationType.DRIVE, + }); + processedEntryKeys.add(driveEntry.toURL()); + } + + // 5. Trash + const trashEntry = + getEntry(currentState, trashRootKey) as FilesAppEntry | null; + if (trashEntry) { + roots.push({ + key: trashRootKey, + section: NavigationSection.TRASH, + separator: true, + type: NavigationType.TRASH, + }); + processedEntryKeys.add(trashRootKey); + } + + // 6/7. Other volumes. + const volumesOrder = { + [VolumeType.SMB]: 1, + [VolumeType.PROVIDED]: 2, // FSP. + [VolumeType.DOCUMENTS_PROVIDER]: 3, + [VolumeType.REMOVABLE]: 4, + [VolumeType.ARCHIVE]: 5, + [VolumeType.MTP]: 6, + }; + // Filter volumes based on the volumeInfoList in volumeManager. + // TODO: get volume manger properly. + const {volumeManager} = window.fileManager; + const filteredVolumeIds = new Set(); + for (let i = 0; i < volumeManager.volumeInfoList.length; i++) { + filteredVolumeIds.add(volumeManager.volumeInfoList.item(i).volumeId); + } + let filteredVolumes = Object.values(currentState.volumes); + if (filteredVolumeIds) { + filteredVolumes = filteredVolumes.filter( + volume => filteredVolumeIds!.has(volume.volumeId)); + } + + const volumes = filteredVolumes + .filter((v) => { + return ( + // Only display if the entry is resolved. + v.rootKey && + // MyFiles and Drive is already displayed above. + // MediaView volumeType isn't displayed. + !(v.volumeType === VolumeType.DOWNLOADS || + v.volumeType === VolumeType.DRIVE || + v.volumeType === VolumeType.MEDIA_VIEW)); + }) + .sort((v1, v2) => { + const v1Order = volumesOrder[v1.volumeType] ?? 999; + const v2Order = volumesOrder[v2.volumeType] ?? 999; + return v1Order - v2Order; + }); + let lastSection: NavigationSection|null = null; + for (const volume of volumes) { + // Some volumes might be nested inside another volume or entry list, e.g. + // Multiple partition removable volumes can be nested inside a EntryList, or + // GuestOS/Crostini/Android volumes will be nested inside MyFiles, for these + // volumes, we only need to add its parent volume in the navigation roots. + const volumeEntry = getPrefixEntryOrEntry(currentState, volume); + + if (volumeEntry && !processedEntryKeys.has(volumeEntry.toURL())) { + const section = + sections.get(volume.volumeType) ?? NavigationSection.REMOVABLE; + const isSectionStart = section !== lastSection; + roots.push({ + key: volumeEntry.toURL(), + section, + separator: isSectionStart, + type: NavigationType.VOLUME, + }); + processedEntryKeys.add(volumeEntry.toURL()); + lastSection = section; + } + } + + // 8. Android Apps. + Object + .values( + androidApps as Record) + .forEach((app, index) => { + roots.push({ + key: app.packageName, + section: NavigationSection.ANDROID_APPS, + separator: index === 0, + type: NavigationType.ANDROID_APPS, + }); + processedEntryKeys.add(app.packageName); + }); + + return { + ...currentState, + navigation: { + roots, + }, + }; +} + +export function updateNavigationEntry( + currentState: State, action: UpdateNavigationEntryAction): State { + const {key, expanded} = action.payload; + const fileData = getFileData(currentState, key); + if (!fileData) { + return currentState; + } + + currentState.allEntries[key] = { + ...fileData, + expanded, + }; + return {...currentState}; +} diff --git a/ui/file_manager/file_manager/state/reducers/navigation_unittest.ts b/ui/file_manager/file_manager/state/reducers/navigation_unittest.ts new file mode 100644 index 0000000000000..6dbb32629089e --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/navigation_unittest.ts @@ -0,0 +1,609 @@ +// 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 {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js'; + +import {MockVolumeManager} from '../../background/js/mock_volume_manager.js'; +import {EntryList, FakeEntryImpl, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {MockFileEntry, MockFileSystem} from '../../common/js/mock_entry.js'; +import {TrashRootEntry} from '../../common/js/trash.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {EntryType, FileData, NavigationSection, NavigationType, Volume} from '../../externs/ts/state.js'; +import {refreshNavigationRoots as refreshNavigationRootsAction, updateNavigationEntry as updateNavigationEntryAction} from '../actions/navigation.js'; +import {createFakeFileData, createFakeVolume, setUpFileManagerOnWindow} from '../for_tests.js'; +import {getEmptyState} from '../store.js'; + +import {refreshNavigationRoots, updateNavigationEntry} from './navigation.js'; +import {driveRootEntryListKey, myFilesEntryListKey, recentRootKey, trashRootKey} from './volumes.js'; + +export function setUp() { + setUpFileManagerOnWindow(); +} + +/** Create FileData for recent entry. */ +function createRecentFileData(): FileData { + const recentEntry = new FakeEntryImpl( + 'Recent', VolumeManagerCommon.RootType.RECENT, + chrome.fileManagerPrivate.SourceRestriction.ANY_SOURCE, + chrome.fileManagerPrivate.FileCategory.ALL); + return createFakeFileData({ + entry: recentEntry, + label: 'Recent', + type: EntryType.RECENT, + }); +} + +/** Create FileData for shortcut entry. */ +function createShortcutEntryFileData( + fileSystemName: string, entryName: string, label: string): FileData { + const fakeFs = new MockFileSystem(fileSystemName); + const shortcutEntry = MockFileEntry.create(fakeFs, `/root/${entryName}`); + return createFakeFileData({ + entry: shortcutEntry, + label, + type: EntryType.FS_API, + }); +} + +/** Create FileData for MyFiles entry. */ +function createMyFilesEntryFileData(): {fileData: FileData, volume: Volume} { + const {volumeManager} = window.fileManager; + const downloadsVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const myFilesEntry = new VolumeEntry(downloadsVolumeInfo); + const fileData = createFakeFileData({ + entry: myFilesEntry, + volumeType: VolumeManagerCommon.VolumeType.DOWNLOADS, + label: 'My files', + type: EntryType.VOLUME_ROOT, + }); + const volume = createFakeVolume({ + volumeId: downloadsVolumeInfo.volumeId, + volumeType: fileData.volumeType!, + label: fileData.label, + rootKey: fileData.entry.toURL(), + }); + return {fileData, volume}; +} + +/** Create FileData for drive root entry. */ +function createDriveRootEntryFileData(): FileData { + const driveEntry = new EntryList( + 'Google Drive', VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT); + return createFakeFileData({ + entry: driveEntry, + label: 'Google Drive', + type: EntryType.ENTRY_LIST, + }); +} + +/** Create FileData for trash entry. */ +function createTrashEntryFileData(): FileData { + const trashEntry = new TrashRootEntry(); + return createFakeFileData({ + entry: trashEntry, + label: 'Bin', + type: EntryType.TRASH, + }); +} + +/** Create android apps. */ +function createAndroidApps(): [ + chrome.fileManagerPrivate.AndroidApp, chrome.fileManagerPrivate.AndroidApp +] { + return [ + { + name: 'App 1', + packageName: 'com.test.app1', + activityName: 'Activity1', + iconSet: {icon16x16Url: 'url1', icon32x32Url: 'url2'}, + }, + { + name: 'App 2', + packageName: 'com.test.app2', + activityName: 'Activity2', + iconSet: {icon16x16Url: 'url3', icon32x32Url: 'url4'}, + }, + ]; +} + +/** Create file data and volume data for volume. */ +function createVolumeFileData( + volumeType: VolumeManagerCommon.VolumeType, volumeId: string, + label: string = '', + devicePath: string = ''): {fileData: FileData, volume: Volume} { + const volumeInfo = MockVolumeManager.createMockVolumeInfo( + volumeType, volumeId, label, devicePath); + const {volumeManager} = window.fileManager; + volumeManager.volumeInfoList.add(volumeInfo); + const volumeEntry = new VolumeEntry(volumeInfo); + const fileData = createFakeFileData({ + entry: volumeEntry, + label, + type: EntryType.VOLUME_ROOT, + }); + const volume = createFakeVolume({ + volumeId: volumeInfo.volumeId, + volumeType: volumeInfo.volumeType, + label, + rootKey: volumeEntry.toURL(), + devicePath, + }); + return {fileData, volume}; +} + +/** + * Tests that navigation roots with all different types: + * 1. produces the expected order of volumes. + * 2. manages NavigationSection for the relevant volumes. + * 3. keeps MTP/Archive/Removable volumes on the original order. + */ +export function testNavigationRoots() { + const currentState = getEmptyState(); + // Put recent entry in the store. + const recentEntryFileData = createRecentFileData(); + currentState.allEntries[recentRootKey] = recentEntryFileData; + // Put 2 shortcut entries in the store. + const shortcutEntryFileData1 = + createShortcutEntryFileData('drive', 'shortcut1', 'Shortcut 1'); + currentState.allEntries[shortcutEntryFileData1.entry.toURL()] = + shortcutEntryFileData1; + currentState.folderShortcuts.push(shortcutEntryFileData1.entry.toURL()); + const shortcutEntryFileData2 = + createShortcutEntryFileData('drive', 'shortcut2', 'Shortcut 2'); + currentState.allEntries[shortcutEntryFileData2.entry.toURL()] = + shortcutEntryFileData2; + currentState.folderShortcuts.push(shortcutEntryFileData2.entry.toURL()); + // Put MyFiles entry in the store. + const myFilesVolume = createMyFilesEntryFileData(); + currentState.allEntries[myFilesVolume.fileData.entry.toURL()] = + myFilesVolume.fileData; + currentState.volumes[myFilesVolume.volume.volumeId] = myFilesVolume.volume; + // Put drive entry in the store. + const driveRootEntryFileData = createDriveRootEntryFileData(); + currentState.allEntries[driveRootEntryListKey] = driveRootEntryFileData; + // Put trash entry in the store. + const trashEntryFileData = createTrashEntryFileData(); + currentState.allEntries[trashRootKey] = trashEntryFileData; + // Put the android apps in the store. + const androidAppsData = createAndroidApps(); + currentState.androidApps[androidAppsData[0].packageName] = androidAppsData[0]; + currentState.androidApps[androidAppsData[1].packageName] = androidAppsData[1]; + + // Create different volumes. + const providerVolume1 = createVolumeFileData( + VolumeManagerCommon.VolumeType.PROVIDED, 'provided:prov1'); + currentState.allEntries[providerVolume1.fileData.entry.toURL()] = + providerVolume1.fileData; + currentState.volumes[providerVolume1.volume.volumeId] = + providerVolume1.volume; + + // Set the device paths of the removable volumes to different strings to + // test the behavior of two physically separate external devices. + const hogeVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:hoge', 'Hoge', + 'device/path/1'); + currentState.allEntries[hogeVolume.fileData.entry.toURL()] = + hogeVolume.fileData; + currentState.volumes[hogeVolume.volume.volumeId] = hogeVolume.volume; + + const fugaVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:fuga', 'Fuga', + 'device/path/2'); + currentState.allEntries[fugaVolume.fileData.entry.toURL()] = + fugaVolume.fileData; + currentState.volumes[fugaVolume.volume.volumeId] = fugaVolume.volume; + + const archiveVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.ARCHIVE, 'archive:a-rar'); + currentState.allEntries[archiveVolume.fileData.entry.toURL()] = + archiveVolume.fileData; + currentState.volumes[archiveVolume.volume.volumeId] = archiveVolume.volume; + + const mtpVolume = + createVolumeFileData(VolumeManagerCommon.VolumeType.MTP, 'mtp:a-phone'); + currentState.allEntries[mtpVolume.fileData.entry.toURL()] = + mtpVolume.fileData; + currentState.volumes[mtpVolume.volume.volumeId] = mtpVolume.volume; + + const providerVolume2 = createVolumeFileData( + VolumeManagerCommon.VolumeType.PROVIDED, 'provided:prov2'); + currentState.allEntries[providerVolume2.fileData.entry.toURL()] = + providerVolume2.fileData; + currentState.volumes[providerVolume2.volume.volumeId] = + providerVolume2.volume; + + const androidFilesVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.ANDROID_FILES, 'android_files:droid'); + androidFilesVolume.volume.prefixKey = myFilesVolume.fileData.entry.toURL(); + currentState.allEntries[androidFilesVolume.fileData.entry.toURL()] = + androidFilesVolume.fileData; + currentState.volumes[androidFilesVolume.volume.volumeId] = + androidFilesVolume.volume; + + const smbVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.SMB, 'smb:file-share'); + currentState.allEntries[smbVolume.fileData.entry.toURL()] = + smbVolume.fileData; + currentState.volumes[smbVolume.volume.volumeId] = smbVolume.volume; + + const newState = + refreshNavigationRoots(currentState, refreshNavigationRootsAction()); + // Navigation roots built above: + // 1. fake-entry://recent + // 2. /root/shortcut1 + // 3. /root/shortcut2 + // 4. My files + // * Android files - won't be included as root because it's inside + // MyFiles. + // 5. Drive + // 6. Trash + // 7. smb:file-share + // 8. provided:prov1 + // 9. provided:prov2 + // + // 10. removable:hoge + // 11. removable:fuga + // 12. archive:a-rar - mounted as archive + // 13. mtp:a-phone + // + // 14. android:app1 + // 15. android:app2 + + // Check items order and that MTP/Archive/Removable respect the original + // order. + const {roots} = newState.navigation; + assertEquals(15, roots.length); + + // recent. + assertEquals(recentEntryFileData.entry.toURL(), roots[0]!.key); + assertEquals(NavigationSection.TOP, roots[0]!.section); + assertEquals(false, roots[0]!.separator); + assertEquals(NavigationType.RECENT, roots[0]!.type); + // shortcut1. + assertEquals(shortcutEntryFileData1.entry.toURL(), roots[1]!.key); + assertEquals(NavigationSection.TOP, roots[1]!.section); + assertEquals(false, roots[1]!.separator); + assertEquals(NavigationType.SHORTCUT, roots[1]!.type); + // shortcut2. + assertEquals(shortcutEntryFileData2.entry.toURL(), roots[2]!.key); + assertEquals(NavigationSection.TOP, roots[2]!.section); + assertEquals(false, roots[2]!.separator); + assertEquals(NavigationType.SHORTCUT, roots[2]!.type); + + // My Files. + assertEquals(myFilesVolume.fileData.entry.toURL(), roots[3]!.key); + assertEquals(NavigationSection.MY_FILES, roots[3]!.section); + assertEquals(true, roots[3]!.separator); + assertEquals(NavigationType.VOLUME, roots[3]!.type); + + // Drive. + assertEquals(driveRootEntryFileData.entry.toURL(), roots[4]!.key); + assertEquals(NavigationSection.CLOUD, roots[4]!.section); + assertEquals(true, roots[4]!.separator); + assertEquals(NavigationType.DRIVE, roots[4]!.type); + + // Trash. + assertEquals(trashEntryFileData.entry.toURL(), roots[5]!.key); + assertEquals(NavigationSection.TRASH, roots[5]!.section); + assertEquals(true, roots[5]!.separator); + assertEquals(NavigationType.TRASH, roots[5]!.type); + + // FSP, and SMB are grouped together. + // smb:file-share. + assertEquals(smbVolume.fileData.entry.toURL(), roots[6]!.key); + assertEquals(NavigationSection.CLOUD, roots[6]!.section); + assertEquals(true, roots[6]!.separator); + assertEquals(NavigationType.VOLUME, roots[6]!.type); + // provided:prov1. + assertEquals(providerVolume1.fileData.entry.toURL(), roots[7]!.key); + assertEquals(NavigationSection.CLOUD, roots[7]!.section); + assertEquals(false, roots[7]!.separator); + assertEquals(NavigationType.VOLUME, roots[7]!.type); + // provided:prov2. + assertEquals(providerVolume2.fileData.entry.toURL(), roots[8]!.key); + assertEquals(NavigationSection.CLOUD, roots[8]!.section); + assertEquals(false, roots[8]!.separator); + assertEquals(NavigationType.VOLUME, roots[8]!.type); + + // MTP/Archive/Removable are grouped together. + // removable:hoge. + assertEquals(hogeVolume.fileData.entry.toURL(), roots[9]!.key); + assertEquals(NavigationSection.REMOVABLE, roots[9]!.section); + assertEquals(true, roots[9]!.separator); + assertEquals(NavigationType.VOLUME, roots[9]!.type); + // removable:fuga. + assertEquals(fugaVolume.fileData.entry.toURL(), roots[10]!.key); + assertEquals(NavigationSection.REMOVABLE, roots[10]!.section); + assertEquals(false, roots[10]!.separator); + assertEquals(NavigationType.VOLUME, roots[10]!.type); + // archive:a-rar. + assertEquals(archiveVolume.fileData.entry.toURL(), roots[11]!.key); + assertEquals(NavigationSection.REMOVABLE, roots[11]!.section); + assertEquals(false, roots[11]!.separator); + assertEquals(NavigationType.VOLUME, roots[11]!.type); + // mtp:a-phone. + assertEquals(mtpVolume.fileData.entry.toURL(), roots[12]!.key); + assertEquals(NavigationSection.REMOVABLE, roots[12]!.section); + assertEquals(false, roots[12]!.separator); + assertEquals(NavigationType.VOLUME, roots[12]!.type); + + // android:app1 + assertEquals(androidAppsData[0].packageName, roots[13]!.key); + assertEquals(NavigationSection.ANDROID_APPS, roots[13]!.section); + assertEquals(true, roots[13]!.separator); + assertEquals(NavigationType.ANDROID_APPS, roots[13]!.type); + // android:app2 + assertEquals(androidAppsData[1].packageName, roots[14]!.key); + assertEquals(NavigationSection.ANDROID_APPS, roots[14]!.section); + assertEquals(false, roots[14]!.separator); + assertEquals(NavigationType.ANDROID_APPS, roots[14]!.type); +} + +/** + * Tests navigation roots with no Recents. + */ +export function testNavigationRootsWithoutRecents() { + const currentState = getEmptyState(); + // Put shortcut entry in the store. + const shortcutEntryFileData = + createShortcutEntryFileData('drive', 'shortcut', 'Shortcut'); + currentState.allEntries[shortcutEntryFileData.entry.toURL()] = + shortcutEntryFileData; + currentState.folderShortcuts.push(shortcutEntryFileData.entry.toURL()); + // Put MyFiles entry in the store. + const myFilesVolume = createMyFilesEntryFileData(); + currentState.allEntries[myFilesVolume.fileData.entry.toURL()] = + myFilesVolume.fileData; + currentState.volumes[myFilesVolume.volume.volumeId] = myFilesVolume.volume; + + const newState = + refreshNavigationRoots(currentState, refreshNavigationRootsAction()); + + // Expect 2 navigation roots. + const {roots} = newState.navigation; + assertEquals(2, roots.length); + assertDeepEquals( + { + key: shortcutEntryFileData.entry.toURL(), + section: NavigationSection.TOP, + separator: false, + type: NavigationType.SHORTCUT, + }, + roots[0]); + assertEquals(myFilesVolume.fileData.entry.toURL(), roots[1]!.key); +} + +/** + * Tests navigation roots with fake MyFiles. + */ +export function testNavigationRootsWithFakeMyFiles() { + const currentState = getEmptyState(); + // Put recent entry in the store. + const recentEntryFileData = createRecentFileData(); + currentState.allEntries[recentRootKey] = recentEntryFileData; + // Put MyFiles entry in the store. + const myFilesEntryList = + new EntryList('My files', VolumeManagerCommon.RootType.MY_FILES); + currentState.allEntries[myFilesEntryList.toURL()] = createFakeFileData({ + entry: myFilesEntryList, + label: 'My files', + type: EntryType.ENTRY_LIST, + }); + + const newState = + refreshNavigationRoots(currentState, refreshNavigationRootsAction()); + + // Expect 2 navigation roots. + const {roots} = newState.navigation; + assertEquals(2, roots.length); + // The type of MyFiles navigation root should be ENTRY_LIST. + assertDeepEquals( + { + key: myFilesEntryList.toURL(), + section: NavigationSection.MY_FILES, + separator: true, + type: NavigationType.ENTRY_LIST, + }, + roots[1]); +} + +/** + * Tests navigation roots with volumes. + */ +export function testNavigationRootsWithVolumes() { + const currentState = getEmptyState(); + // Put recent entry in the store. + const recentEntryFileData = createRecentFileData(); + currentState.allEntries[recentRootKey] = recentEntryFileData; + // Put MyFiles entry in the store. + const myFilesVolume = createMyFilesEntryFileData(); + currentState.allEntries[myFilesVolume.fileData.entry.toURL()] = + myFilesVolume.fileData; + currentState.volumes[myFilesVolume.volume.volumeId] = myFilesVolume.volume; + // Put drive entry in the store. + const driveRootEntryFileData = createDriveRootEntryFileData(); + currentState.allEntries[driveRootEntryListKey] = driveRootEntryFileData; + + // Put removable volume 'hoge' in the store. + const hogeVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:hoge', 'Hoge', + 'device/path/1'); + currentState.allEntries[hogeVolume.fileData.entry.toURL()] = + hogeVolume.fileData; + currentState.volumes[hogeVolume.volume.volumeId] = hogeVolume.volume; + + // Create a shortcut for the 'hoge' volume in the store. + const hogeShortcutEntryFileData = createShortcutEntryFileData( + hogeVolume.volume.volumeId, 'shortcut-hoge', 'Hoge shortcut'); + currentState.allEntries[hogeShortcutEntryFileData.entry.toURL()] = + hogeShortcutEntryFileData; + currentState.folderShortcuts.push(hogeShortcutEntryFileData.entry.toURL()); + + // Put removable volume 'fuga' in the store. Not a partition, so set a + // different device path to 'hoge'. + const fugaVolume = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:fuga', 'Fuga', + 'device/path/2'); + currentState.allEntries[fugaVolume.fileData.entry.toURL()] = + fugaVolume.fileData; + currentState.volumes[fugaVolume.volume.volumeId] = fugaVolume.volume; + + const newState = + refreshNavigationRoots(currentState, refreshNavigationRootsAction()); + + // Expect 6 navigation roots. + const {roots} = newState.navigation; + assertEquals(6, roots.length); + assertEquals(recentEntryFileData.entry.toURL(), roots[0]!.key); + assertDeepEquals( + { + key: hogeShortcutEntryFileData.entry.toURL(), + section: NavigationSection.TOP, + separator: false, + type: NavigationType.SHORTCUT, + }, + roots[1]); + assertEquals(myFilesVolume.fileData.entry.toURL(), roots[2]!.key); + assertEquals(driveRootEntryFileData.entry.toURL(), roots[3]!.key); + assertDeepEquals( + { + key: hogeVolume.fileData.entry.toURL(), + section: NavigationSection.REMOVABLE, + separator: true, + type: NavigationType.VOLUME, + }, + roots[4]); + assertDeepEquals( + { + key: fugaVolume.fileData.entry.toURL(), + section: NavigationSection.REMOVABLE, + separator: false, + type: NavigationType.VOLUME, + }, + roots[5]); +} + +/** + * Tests that for multiple partition volumes, only the parent entry will be + * added to the navigation roots. + */ +export function testMultipleUsbPartitionsGrouping() { + const currentState = getEmptyState(); + + // Add parent entry list to the store. + const devicePath = 'device/path/1'; + const parentEntry = new EntryList( + 'Partition wrap', VolumeManagerCommon.RootType.REMOVABLE, devicePath); + currentState.allEntries[parentEntry.toURL()] = createFakeFileData({ + entry: parentEntry, + label: 'Partition wrap', + type: EntryType.ENTRY_LIST, + }); + // Create 3 volumes with the same device path so the partitions are grouped. + const partitionVolume1 = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:partition1', + 'partition1', devicePath); + partitionVolume1.volume.prefixKey = parentEntry.toURL(); + currentState.allEntries[partitionVolume1.fileData.entry.toURL()] = + partitionVolume1.fileData; + currentState.volumes[partitionVolume1.volume.volumeId] = + partitionVolume1.volume; + const partitionVolume2 = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:partition2', + 'partition2', devicePath); + currentState.allEntries[partitionVolume2.fileData.entry.toURL()] = + partitionVolume2.fileData; + currentState.volumes[partitionVolume2.volume.volumeId] = + partitionVolume2.volume; + partitionVolume2.volume.prefixKey = parentEntry.toURL(); + const partitionVolume3 = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:partition3', + 'partition3', devicePath); + currentState.allEntries[partitionVolume3.fileData.entry.toURL()] = + partitionVolume3.fileData; + currentState.volumes[partitionVolume3.volume.volumeId] = + partitionVolume3.volume; + partitionVolume3.volume.prefixKey = parentEntry.toURL(); + + const newState = + refreshNavigationRoots(currentState, refreshNavigationRootsAction()); + + // Expect only the parent entry and MyFiles being added to the navigation + // roots. + const {roots} = newState.navigation; + assertEquals(2, roots.length); + assertEquals(myFilesEntryListKey, roots[0]!.key); + assertDeepEquals( + { + key: parentEntry.toURL(), + section: NavigationSection.REMOVABLE, + separator: true, + type: NavigationType.VOLUME, + }, + roots[1]); +} + +/** + * Tests that the volumes filtered by the volume manager won't be shown in the + * navigation roots. + */ +export function testNavigationRootsWithFilteredVolume() { + const currentState = getEmptyState(); + // Put 2 volumes in the store. + const volume1 = createVolumeFileData( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable1'); + currentState.allEntries[volume1.fileData.entry.toURL()] = volume1.fileData; + currentState.volumes[volume1.volume.volumeId] = volume1.volume; + // Volume2 is not added to VolumeManager's volumeInfoList. + const volumeInfo2 = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable2'); + const volumeEntry2 = new VolumeEntry(volumeInfo2); + currentState.allEntries[volumeEntry2.toURL()] = createFakeFileData({ + entry: volumeEntry2, + label: volumeInfo2.label, + type: EntryType.VOLUME_ROOT, + }); + currentState.volumes[volumeInfo2.volumeId] = createFakeVolume({ + volumeId: volumeInfo2.volumeId, + volumeType: volumeInfo2.volumeType, + label: volumeInfo2.label, + rootKey: volumeEntry2.toURL(), + }); + + const newState = + refreshNavigationRoots(currentState, refreshNavigationRootsAction()); + + // Expect only volume1 and MyFiles in the navigation roots. + const {roots} = newState.navigation; + assertEquals(2, roots.length); + assertEquals(myFilesEntryListKey, roots[0]!.key); + assertEquals(volume1.fileData.entry.toURL(), roots[1]!.key); +} + +/** Tests that navigation entry can be updated correctly. */ +export function testUpdateNavigationEntry() { + const currentState = getEmptyState(); + // Add MyFiles entry to the store. + const myFilesVolume = createMyFilesEntryFileData(); + const myFilesEntryKey = myFilesVolume.fileData.entry.toURL(); + currentState.allEntries[myFilesEntryKey] = myFilesVolume.fileData; + + assertFalse(currentState.allEntries[myFilesEntryKey].expanded); + const newState = updateNavigationEntry( + currentState, + updateNavigationEntryAction({key: myFilesEntryKey, expanded: true})); + assertTrue(newState.allEntries[myFilesEntryKey].expanded); +} + +/** Tests that navigation entry won't be updated without valid file data. */ +export function testUpdateNavigationEntryWithoutValidFileData() { + const currentState = getEmptyState(); + + const newState = updateNavigationEntry( + currentState, + updateNavigationEntryAction({key: 'not-exist-key', expanded: true})); + // Check state won't be touched. + assertEquals(currentState, newState); +} diff --git a/ui/file_manager/file_manager/state/reducers/root.ts b/ui/file_manager/file_manager/state/reducers/root.ts index cf340a9d39f2e..90670acdf4d3d 100644 --- a/ui/file_manager/file_manager/state/reducers/root.ts +++ b/ui/file_manager/file_manager/state/reducers/root.ts @@ -5,9 +5,14 @@ import {State} from '../../externs/ts/state.js'; import {Action, ActionType} from '../actions.js'; -import {cacheEntries, clearCachedEntries, updateMetadata} from './all_entries.js'; +import {addChildEntries, cacheEntries, clearCachedEntries, updateMetadata} from './all_entries.js'; +import {addAndroidApps} from './android_apps.js'; import {changeDirectory, updateDirectoryContent, updateFileTasks, updateSelection} from './current_directory.js'; +import {addFolderShortcut, refreshFolderShortcut, removeFolderShortcut} from './folder_shortcuts.js'; +import {refreshNavigationRoots, updateNavigationEntry} from './navigation.js'; import {search} from './search.js'; +import {addUiEntry, removeUiEntry} from './ui_entries.js'; +import {addVolume, removeVolume} from './volumes.js'; /** * Root reducer for the State for Files app. @@ -37,8 +42,31 @@ export function rootReducer(currentState: State, action: Action): State { return updateDirectoryContent(state, action); case ActionType.UPDATE_METADATA: return updateMetadata(state, action); + case ActionType.ADD_VOLUME: + return addVolume(currentState, action); + case ActionType.REMOVE_VOLUME: + return removeVolume(currentState, action); + case ActionType.REFRESH_NAVIGATION_ROOTS: + return refreshNavigationRoots(currentState, action); + case ActionType.UPDATE_NAVIGATION_ENTRY: + return updateNavigationEntry(currentState, action); + case ActionType.ADD_UI_ENTRY: + return addUiEntry(currentState, action); + case ActionType.REMOVE_UI_ENTRY: + return removeUiEntry(currentState, action); + case ActionType.REFRESH_FOLDER_SHORTCUT: + return refreshFolderShortcut(currentState, action); + case ActionType.ADD_FOLDER_SHORTCUT: + return addFolderShortcut(currentState, action); + case ActionType.REMOVE_FOLDER_SHORTCUT: + return removeFolderShortcut(currentState, action); + case ActionType.ADD_ANDROID_APPS: + return addAndroidApps(currentState, action); + case ActionType.ADD_CHILD_ENTRIES: + return addChildEntries(currentState, action); default: - console.error(`invalid action: ${action}`); + console.error(`invalid action type: ${(action as any)?.type} action: ${ + JSON.stringify(action)}`); return state; } } diff --git a/ui/file_manager/file_manager/state/reducers/ui_entries.ts b/ui/file_manager/file_manager/state/reducers/ui_entries.ts new file mode 100644 index 0000000000000..2c0f8f9b44629 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/ui_entries.ts @@ -0,0 +1,104 @@ +// 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 {isVolumeEntry, sortEntries} from '../../common/js/entry_utils.js'; +import {FakeEntryImpl} from '../../common/js/files_app_entry_types.js'; +import {util} from '../../common/js/util.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {State} from '../../externs/ts/state.js'; +import {AddUiEntryAction, RemoveUiEntryAction} from '../actions/ui_entries.js'; +import {getEntry, getFileData} from '../store.js'; + +import {getMyFiles} from './all_entries.js'; + +const uiEntryRootTypesInMyFiles = new Set([ + VolumeManagerCommon.RootType.ANDROID_FILES, + VolumeManagerCommon.RootType.CROSTINI, + VolumeManagerCommon.RootType.GUEST_OS, +]); + +export function addUiEntry( + currentState: State, action: AddUiEntryAction): State { + const {entry} = action.payload; + const key = entry.toURL(); + + let isVolumeEntryExistedInMyFiles = false; + if (uiEntryRootTypesInMyFiles.has(entry.rootType)) { + const {myFilesEntry} = getMyFiles(currentState); + const children = myFilesEntry.getUIChildren(); + // Check if the the ui entry already has a corresponding volume entry. + isVolumeEntryExistedInMyFiles = !!children.find( + childEntry => + isVolumeEntry(childEntry) && childEntry.name === entry.name); + const isUiEntryExistedInMyFiles = + !!children.find(childEntry => util.isSameEntry(childEntry, entry)); + // We only add the UI entry here if: + // 1. it is not existed in MyFiles entry + // 2. its corresponding volume (which ui entry is a placeholder for) is not + // existed in MyFiles entry + const shouldAddUiEntry = + !isUiEntryExistedInMyFiles && !isVolumeEntryExistedInMyFiles; + if (shouldAddUiEntry) { + myFilesEntry.addEntry(entry); + // Push the new entry to the children of FileData and sort them. + const fileData = getFileData(currentState, myFilesEntry.toURL()); + if (fileData) { + fileData.children.push(entry.toURL()); + const childEntries = fileData.children.map( + childKey => getEntry(currentState, childKey)!); + const newChildren = + sortEntries(myFilesEntry, childEntries).map(entry => entry.toURL()); + currentState.allEntries[myFilesEntry.toURL()] = { + ...fileData, + children: newChildren, + }; + } + } + } + + // If the corresponding volume entry exists, we don't add the ui entry here. + if (!currentState.uiEntries.find(k => k === key) && + !isVolumeEntryExistedInMyFiles) { + // Shallow copy. + currentState.uiEntries = currentState.uiEntries.slice(); + currentState.uiEntries.push(key); + } + + return { + ...currentState, + }; +} + +export function removeUiEntry( + currentState: State, action: RemoveUiEntryAction): State { + const key = action.payload.key; + const entry = getEntry(currentState, key) as FakeEntryImpl | null; + if (currentState.uiEntries.find(k => k === key)) { + // Shallow copy. + currentState.uiEntries = currentState.uiEntries.filter(k => k !== key); + } + + // We also need to remove it from the children of MyFiles if it's existed + // there. + if (entry && uiEntryRootTypesInMyFiles.has(entry.rootType)) { + const {myFilesEntry} = getMyFiles(currentState); + const children = myFilesEntry.getUIChildren(); + const isUiEntryExistedInMyFiles = + !!children.find(childEntry => util.isSameEntry(childEntry, entry)); + if (isUiEntryExistedInMyFiles) { + myFilesEntry.removeChildEntry(entry); + const fileData = getFileData(currentState, myFilesEntry.toURL()); + if (fileData) { + currentState.allEntries[myFilesEntry.toURL()] = { + ...fileData, + children: (fileData.children || []).filter(child => child !== key), + }; + } + } + } + + return { + ...currentState, + }; +} diff --git a/ui/file_manager/file_manager/state/reducers/ui_entries_unittest.ts b/ui/file_manager/file_manager/state/reducers/ui_entries_unittest.ts new file mode 100644 index 0000000000000..ae1d835434d7b --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/ui_entries_unittest.ts @@ -0,0 +1,250 @@ +// 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 {assertArrayEquals, assertEquals} from 'chrome://webui-test/chai_assert.js'; + +import {MockVolumeManager} from '../../background/js/mock_volume_manager.js'; +import {FakeEntryImpl, GuestOsPlaceholder, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {EntryType, FileData, State} from '../../externs/ts/state.js'; +import {VolumeInfo} from '../../externs/volume_info.js'; +import {addUiEntry as addUiEntryAction, removeUiEntry as removeUiEntryAction} from '../actions/ui_entries.js'; +import {createFakeFileData, createFakeVolume, setUpFileManagerOnWindow} from '../for_tests.js'; +import {getEmptyState} from '../store.js'; + +import {addUiEntry, removeUiEntry} from './ui_entries.js'; + +export function setUp() { + // sortEntries() from addUiEntry() reducer requires volumeManager and + // directoryModel on window. + setUpFileManagerOnWindow(); +} + +/** Generate MyFiles entry with real volume entry. */ +function createMyFilesDataWithVolumeEntry(): + {fileData: FileData, volumeInfo: VolumeInfo} { + const {volumeManager} = window.fileManager; + const downloadsVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const fileData = createFakeFileData({ + entry: new VolumeEntry(downloadsVolumeInfo), + volumeType: VolumeManagerCommon.VolumeType.DOWNLOADS, + label: 'My files', + type: EntryType.VOLUME_ROOT, + }); + return {fileData, volumeInfo: downloadsVolumeInfo}; +} + +/** Tests a normal ui entry can be added correctly. */ +export function testAddUiEntry() { + const currentState: State = getEmptyState(); + const uiEntry = + new FakeEntryImpl('Ui entry', VolumeManagerCommon.RootType.RECENT); + const newState = addUiEntry(currentState, addUiEntryAction({entry: uiEntry})); + assertEquals(1, newState.uiEntries.length); + assertEquals(uiEntry.toURL(), newState.uiEntries[0]); +} + +/** Tests that a duplicate ui entry won't be added. */ +export function testAddDuplicateUiEntry() { + const currentState: State = getEmptyState(); + const uiEntry = + new FakeEntryImpl('Ui entry', VolumeManagerCommon.RootType.RECENT); + currentState.uiEntries.push(uiEntry.toURL()); + const newState = addUiEntry(currentState, addUiEntryAction({entry: uiEntry})); + assertEquals(1, newState.uiEntries.length); + assertEquals(uiEntry.toURL(), newState.uiEntries[0]); + // The reference of uiEntries won't change. + assertEquals(currentState.uiEntries, newState.uiEntries); +} + +/** + * Tests that adding ui entry for MyFiles will reset the children filed of + * MyFiles entry. + */ +export function testAddUiEntryForMyFiles() { + const currentState: State = getEmptyState(); + // Setup MyFiles entry in the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesEntry = fileData.entry as VolumeEntry; + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + // Add children to the MyFiles entry. + const childEntry = new GuestOsPlaceholder( + 'Play files', 0, chrome.fileManagerPrivate.VmType.ARCVM); + currentState.allEntries[childEntry.toURL()] = createFakeFileData({ + entry: childEntry, + label: 'Play files', + type: EntryType.PLACEHOLDER, + }); + myFilesEntry.addEntry(childEntry); + fileData.children.push(childEntry.toURL()); + + const uiEntry = + new FakeEntryImpl('Linux files', VolumeManagerCommon.RootType.CROSTINI); + // Before calling addUiEntry(), the new uiEntry should already be in store, + // this is handled by cacheEntries(), we should emulate the process here. This + // is required for sortEntries(). + currentState.allEntries[uiEntry.toURL()] = createFakeFileData({ + entry: uiEntry, + label: 'Linux files', + type: EntryType.PLACEHOLDER, + }); + const newState = addUiEntry(currentState, addUiEntryAction({entry: uiEntry})); + assertEquals(1, newState.uiEntries.length); + assertEquals(uiEntry.toURL(), newState.uiEntries[0]); + // Check the ui entry is added to MyFiles entry. + assertEquals(2, myFilesEntry.getUIChildren().length); + assertEquals(uiEntry, myFilesEntry.getUIChildren()[1]); + // Check the children of MyFiles entry gets updated and resorted. + assertArrayEquals( + [ + uiEntry.toURL(), + childEntry.toURL(), + ], + newState.allEntries[myFilesEntry.toURL()].children); +} + +/** + * Tests that ui entry won't be added to MyFiles if it's already existed. + */ +export function testAddDuplicateUiEntryForMyFiles() { + const currentState: State = getEmptyState(); + const uiEntry = new GuestOsPlaceholder( + 'Play files', 0, chrome.fileManagerPrivate.VmType.ARCVM); + // Setup MyFiles entry and add the new ui entry in the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesEntry = fileData.entry as VolumeEntry; + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + myFilesEntry.addEntry(uiEntry); + + const newState = addUiEntry(currentState, addUiEntryAction({entry: uiEntry})); + assertEquals(1, newState.uiEntries.length); + assertEquals(uiEntry.toURL(), newState.uiEntries[0]); + // Check the ui entry is not being added to MyFiles entry again. + assertEquals(1, myFilesEntry.getUIChildren().length); + assertEquals(uiEntry, myFilesEntry.getUIChildren()[0]); + // Check the children of MyFiles has not been touched. + assertEquals( + newState.allEntries[myFilesEntry.toURL()].children, + currentState.allEntries[myFilesEntry.toURL()].children); +} + +/** + * Tests that ui entry won't be added to MyFiles if the corresponding volume + * is already existed. + */ +export function testAddDuplicateUiEntryForMyFilesWhenVolumeExists() { + const currentState: State = getEmptyState(); + // Placeholder ui entry and the volume entry it represents have the same + // label. + const label = 'Play files'; + const uiEntry = + new GuestOsPlaceholder(label, 0, chrome.fileManagerPrivate.VmType.ARCVM); + // Setup MyFiles entry and add the new volume entry in the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesEntry = fileData.entry as VolumeEntry; + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + const playFilesVolumeInfo = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.ANDROID_FILES, label); + const playFilesVolumeEntry = new VolumeEntry(playFilesVolumeInfo); + myFilesEntry.addEntry(playFilesVolumeEntry); + + const newState = addUiEntry(currentState, addUiEntryAction({entry: uiEntry})); + // Check the ui entry has not been touched. + assertEquals(currentState.uiEntries, newState.uiEntries); + // Check the ui entry is not being added to MyFiles entry again. + assertEquals(1, myFilesEntry.getUIChildren().length); + assertEquals(playFilesVolumeEntry, myFilesEntry.getUIChildren()[0]); + // Check the children of MyFiles has not been touched. + assertEquals( + newState.allEntries[myFilesEntry.toURL()].children, + currentState.allEntries[myFilesEntry.toURL()].children); +} + +/** Tests that ui entry can be removed from store correctly. */ +export function testRemoveUiEntry() { + const currentState: State = getEmptyState(); + const uiEntry = + new FakeEntryImpl('Ui entry', VolumeManagerCommon.RootType.RECENT); + // Setup the ui entry in both uiEntries and allEntries in the store. + currentState.allEntries[uiEntry.toURL()] = createFakeFileData({ + entry: uiEntry, + label: 'Ui entry', + type: EntryType.PLACEHOLDER, + }); + currentState.uiEntries.push(uiEntry.toURL()); + + const newState = + removeUiEntry(currentState, removeUiEntryAction({key: uiEntry.toURL()})); + assertEquals(0, newState.uiEntries.length); +} + +/** Tests that removing non-existed ui entry won't do anything. */ +export function testRemoveNonExistedUiEntry() { + const currentState: State = getEmptyState(); + const uiEntry = + new FakeEntryImpl('Ui entry', VolumeManagerCommon.RootType.TRASH); + const newState = + removeUiEntry(currentState, removeUiEntryAction({key: uiEntry.toURL()})); + // Check that uiEntries won't be touched. + assertEquals(newState.uiEntries, currentState.uiEntries); +} + +/** + * Tests removing ui entry from MyFiles will reset the children field of + * MyFiles entry. + */ +export function testRemoveUiEntryFromMyFiles() { + const currentState: State = getEmptyState(); + const uiEntry = + new FakeEntryImpl('Linux files', VolumeManagerCommon.RootType.CROSTINI); + // Setup MyFiles entry and add the ui entry in the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesEntry = fileData.entry as VolumeEntry; + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + myFilesEntry.addEntry(uiEntry); + fileData.children.push(uiEntry.toURL()); + currentState.allEntries[uiEntry.toURL()] = createFakeFileData({ + entry: uiEntry, + volumeType: VolumeManagerCommon.VolumeType.CROSTINI, + label: 'Linux files', + type: EntryType.PLACEHOLDER, + }); + currentState.uiEntries.push(uiEntry.toURL()); + + const newState = + removeUiEntry(currentState, removeUiEntryAction({key: uiEntry.toURL()})); + assertEquals(0, newState.uiEntries.length); + // Check the ui entry has also been removed from MyFiles entry. + assertEquals(0, myFilesEntry.getUIChildren().length); + assertEquals(0, newState.allEntries[myFilesEntry.toURL()].children.length); +} diff --git a/ui/file_manager/file_manager/state/reducers/volumes.ts b/ui/file_manager/file_manager/state/reducers/volumes.ts new file mode 100644 index 0000000000000..0648723a78aa1 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/volumes.ts @@ -0,0 +1,173 @@ +// 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/ash/common/assert.js'; + +import {EntryList, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {util} from '../../common/js/util.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {PropStatus, State, Volume} from '../../externs/ts/state.js'; +import {VolumeInfo} from '../../externs/volume_info.js'; +import {AddVolumeAction, RemoveVolumeAction} from '../actions/volumes.js'; +import {getEntry} from '../store.js'; + +import {getMyFiles} from './all_entries.js'; + +const VolumeType = VolumeManagerCommon.VolumeType; +export const myFilesEntryListKey = + `entry-list://${VolumeManagerCommon.RootType.MY_FILES}`; +export const crostiniPlaceHolderKey = + `fake-entry://${VolumeManagerCommon.RootType.CROSTINI}`; +export const drivePlaceHolderKey = + `fake-entry://${VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT}`; +export const recentRootKey = + `fake-entry://${VolumeManagerCommon.RootType.RECENT}/all`; +export const trashRootKey = + `fake-entry://${VolumeManagerCommon.RootType.TRASH}`; +export const driveRootEntryListKey = + `entry-list://${VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT}`; +export const makeRemovableParentKey = + (volume: Volume|chrome.fileManagerPrivate.VolumeMetadata) => + `entry-list://${VolumeManagerCommon.RootType.REMOVABLE}/${ + volume.devicePath}`; +export const removableGroupKey = + (volume: Volume|chrome.fileManagerPrivate.VolumeMetadata) => + `${volume.devicePath}/${volume.driveLabel}`; + +export function getVolumeTypesNestedInMyFiles() { + const myFilesNestedVolumeTypes = new Set([ + VolumeType.ANDROID_FILES, + VolumeType.CROSTINI, + ]); + if (util.isGuestOsEnabled()) { + myFilesNestedVolumeTypes.add(VolumeType.GUEST_OS); + } + return myFilesNestedVolumeTypes; +} + +/** + * Convert VolumeInfo and VolumeMetadata to its store representation: Volume. + */ +export function convertVolumeInfoAndMetadataToVolume( + volumeInfo: VolumeInfo, + volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata): Volume { + /** + * FileKey for the volume root's Entry. Or how do we find the Entry for this + * volume in the allEntries. + */ + const volumeRootKey = volumeInfo.displayRoot.toURL(); + return { + volumeId: volumeMetadata.volumeId, + volumeType: volumeMetadata.volumeType, + rootKey: volumeRootKey, + status: PropStatus.SUCCESS, + label: volumeInfo.label, + error: volumeMetadata.mountCondition, + deviceType: volumeMetadata.deviceType, + devicePath: volumeMetadata.devicePath, + isReadOnly: volumeMetadata.isReadOnly, + isReadOnlyRemovableDevice: volumeMetadata.isReadOnlyRemovableDevice, + providerId: volumeMetadata.providerId, + configurable: volumeMetadata.configurable, + watchable: volumeMetadata.watchable, + source: volumeMetadata.source, + diskFileSystemType: volumeMetadata.diskFileSystemType, + iconSet: volumeMetadata.iconSet, + driveLabel: volumeMetadata.driveLabel, + vmType: volumeMetadata.vmType, + + isDisabled: false, + // FileKey to volume's parent in the Tree. + prefixKey: undefined, + }; +} + +export function addVolume(currentState: State, action: AddVolumeAction): State { + const volumeMetadata = action.payload.volumeMetadata; + const volumeInfo = action.payload.volumeInfo; + if (!volumeInfo.fileSystem) { + console.error( + 'Only add to the store volumes that have successfully resolved.'); + return currentState; + } + + const volumes = { + ...currentState.volumes, + }; + const volume = + convertVolumeInfoAndMetadataToVolume(volumeInfo, volumeMetadata); + const volumeEntry = getEntry(currentState, volume.rootKey!); + // Use volume entry's disabled property because that one is derived from + // volume manager. + if (volumeEntry) { + volume.isDisabled = !!(volumeEntry as VolumeEntry).disabled; + } + + // Nested in MyFiles. + const myFilesNestedVolumeTypes = getVolumeTypesNestedInMyFiles(); + + // When mounting MyFiles replace the temporary placeholder in nested volumes. + if (volume.volumeType === VolumeType.DOWNLOADS) { + for (const v of Object.values(volumes)) { + if (myFilesNestedVolumeTypes.has(v.volumeType)) { + v.prefixKey = volume.rootKey; + } + } + } + + // When mounting a nested volume, set the prefixKey. + if (myFilesNestedVolumeTypes.has(volume.volumeType)) { + const {myFilesEntry} = getMyFiles(currentState); + volume.prefixKey = myFilesEntry.toURL(); + } + + // When mounting Drive. + if (volume.volumeType === VolumeType.DRIVE) { + const drive = getEntry(currentState, driveRootEntryListKey) as EntryList; + assert(drive); + volume.prefixKey = drive!.toURL(); + } + + // When mounting Removable. + if (volume.volumeType === VolumeType.REMOVABLE) { + // Should it it be nested or not? + const groupingKey = removableGroupKey(volume); + const parentKey = makeRemovableParentKey(volume); + const groupParentEntry = getEntry(currentState, parentKey); + if (groupParentEntry) { + const volumesInSameGroup = Object.values(volumes).filter(v => { + if (v.volumeType === VolumeType.REMOVABLE && + removableGroupKey(v) === groupingKey) { + v.prefixKey = parentKey; + return true; + } + + return false; + }); + volume.prefixKey = + volumesInSameGroup.length > 0 ? groupParentEntry?.toURL() : undefined; + } + } + + return { + ...currentState, + volumes: { + ...volumes, + [volume.volumeId]: volume, + }, + }; +} + +export function removeVolume( + currentState: State, action: RemoveVolumeAction): State { + delete currentState.volumes[action.payload.volumeId]; + const volumes = { + ...currentState.volumes, + }; + + return { + ...currentState, + volumes, + }; +} diff --git a/ui/file_manager/file_manager/state/reducers/volumes_unittest.ts b/ui/file_manager/file_manager/state/reducers/volumes_unittest.ts new file mode 100644 index 0000000000000..55bff85b06fb1 --- /dev/null +++ b/ui/file_manager/file_manager/state/reducers/volumes_unittest.ts @@ -0,0 +1,227 @@ +// 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 {assertEquals} from 'chrome://webui-test/chai_assert.js'; + +import {MockVolumeManager} from '../../background/js/mock_volume_manager.js'; +import {EntryList, VolumeEntry} from '../../common/js/files_app_entry_types.js'; +import {VolumeManagerCommon} from '../../common/js/volume_manager_types.js'; +import {EntryType, FileData, PropStatus} from '../../externs/ts/state.js'; +import {VolumeInfo} from '../../externs/volume_info.js'; +import {addVolume as addVolumeAction, removeVolume as removeVolumeAction} from '../actions/volumes.js'; +import {createFakeFileData, createFakeVolume, createFakeVolumeMetadata} from '../for_tests.js'; +import {getEmptyState} from '../store.js'; + +import {addVolume, driveRootEntryListKey, removeVolume} from './volumes.js'; + +/** Generate MyFiles entry with real volume entry. */ +function createMyFilesDataWithVolumeEntry(): + {fileData: FileData, volumeInfo: VolumeInfo} { + const volumeManager = new MockVolumeManager(); + const downloadsVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DOWNLOADS)!; + const fileData = createFakeFileData({ + entry: new VolumeEntry(downloadsVolumeInfo), + volumeType: VolumeManagerCommon.VolumeType.DOWNLOADS, + label: 'My files', + type: EntryType.VOLUME_ROOT, + }); + return {fileData, volumeInfo: downloadsVolumeInfo}; +} + +/** Tests that MyFiles volume can be added correctly. */ +export function testAddMyFilesVolume() { + const currentState = getEmptyState(); + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + // Mark its volume entry as disabled. + (fileData.entry as VolumeEntry).disabled = true; + // Put MyFiles and its sub volumes in the store. + const playFilesVolume = createFakeVolume({ + volumeId: 'playFilesId', + volumeType: VolumeManagerCommon.VolumeType.ANDROID_FILES, + label: 'Play files', + rootKey: 'filesystem:chrome://android-files-url', + }); + currentState.volumes[playFilesVolume.volumeId] = playFilesVolume; + const crostiniFilesVolume = createFakeVolume({ + volumeId: 'volumeInMyFiles2', + volumeType: VolumeManagerCommon.VolumeType.CROSTINI, + label: 'Linux files', + rootKey: 'filesystem:chrome://crostini-files-url', + }); + currentState.volumes[crostiniFilesVolume.volumeId] = crostiniFilesVolume; + + const volumeMetadata = createFakeVolumeMetadata( + {volumeType: volumeInfo.volumeType, volumeId: volumeInfo.volumeId}); + const newState = addVolume(currentState, addVolumeAction({ + volumeInfo, + volumeMetadata, + })); + const volume = newState.volumes[volumeInfo.volumeId]; + // Check all volume fields are set correctly. + assertEquals(volume.volumeId, volumeMetadata.volumeId); + assertEquals(volume.volumeType, volumeMetadata.volumeType); + assertEquals(volume.rootKey, volumeInfo.displayRoot.toURL()); + assertEquals(volume.status, PropStatus.SUCCESS); + assertEquals(volume.label, volumeInfo.label); + assertEquals(volume.error, volumeMetadata.mountCondition); + assertEquals(volume.deviceType, volumeMetadata.deviceType); + assertEquals(volume.devicePath, volumeMetadata.devicePath); + assertEquals(volume.isReadOnly, volumeMetadata.isReadOnly); + assertEquals( + volume.isReadOnlyRemovableDevice, + volumeMetadata.isReadOnlyRemovableDevice); + assertEquals(volume.providerId, volumeMetadata.providerId); + assertEquals(volume.configurable, volumeMetadata.configurable); + assertEquals(volume.watchable, volumeMetadata.watchable); + assertEquals(volume.source, volumeMetadata.source); + assertEquals(volume.diskFileSystemType, volumeMetadata.diskFileSystemType); + assertEquals(volume.iconSet, volumeMetadata.iconSet); + assertEquals(volume.driveLabel, volumeMetadata.driveLabel); + assertEquals(volume.vmType, volumeMetadata.vmType); + // Because its volume entry is disabled. + assertEquals(volume.isDisabled, true); + assertEquals(volume.prefixKey, undefined); + // Check all child volumes has prefix key setup. + assertEquals( + fileData.entry.toURL(), + newState.volumes[playFilesVolume.volumeId].prefixKey); + assertEquals( + fileData.entry.toURL(), + newState.volumes[crostiniFilesVolume.volumeId].prefixKey); +} + +/** Tests that volume nested in MyFiles can be added correctly. */ +export function testAddNestedMyFilesVolume() { + const currentState = getEmptyState(); + // Put MyFiles in the store. + const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry(); + const myFilesVolume = createFakeVolume({ + volumeType: volumeInfo.volumeType, + volumeId: volumeInfo.volumeId, + label: volumeInfo.label, + rootKey: volumeInfo.displayRoot.toURL(), + }); + currentState.allEntries[fileData.entry.toURL()] = fileData; + currentState.volumes[volumeInfo.volumeId] = myFilesVolume; + + const playFilesVolumeInfo = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.ANDROID_FILES, 'playFilesId', + 'Play files'); + const playFilesVolumeMetadata = createFakeVolumeMetadata({ + volumeType: playFilesVolumeInfo.volumeType, + volumeId: playFilesVolumeInfo.volumeId, + }); + const newState = addVolume(currentState, addVolumeAction({ + volumeInfo: playFilesVolumeInfo, + volumeMetadata: playFilesVolumeMetadata, + })); + // Check the newly added volume has prefix key setup. + assertEquals( + fileData.entry.toURL(), + newState.volumes[playFilesVolumeInfo.volumeId].prefixKey); +} + +/** Tests that drive volume can be added correctly. */ +export function testAddDriveVolume(done: () => void) { + const currentState = getEmptyState(); + // Put FakeDriveRoot in the store. + const fakeDriveRootEntry = new EntryList( + 'Google Drive', VolumeManagerCommon.RootType.DRIVE_FAKE_ROOT); + currentState.allEntries[driveRootEntryListKey] = createFakeFileData({ + entry: fakeDriveRootEntry, + label: 'Google Drive', + type: EntryType.ENTRY_LIST, + }); + + const volumeManager = new MockVolumeManager(); + const driveVolumeInfo = volumeManager.getCurrentProfileVolumeInfo( + VolumeManagerCommon.VolumeType.DRIVE)!; + const driveVolumeMetadata = createFakeVolumeMetadata({ + volumeType: driveVolumeInfo.volumeType, + volumeId: driveVolumeInfo.volumeId, + }); + // DriveFS takes time to resolve. + driveVolumeInfo.resolveDisplayRoot(() => { + const newState = addVolume(currentState, addVolumeAction({ + volumeInfo: driveVolumeInfo, + volumeMetadata: driveVolumeMetadata, + })); + // Check the newly added volume has prefix key setup. + assertEquals( + fakeDriveRootEntry.toURL(), + newState.volumes[driveVolumeInfo.volumeId].prefixKey); + + done(); + }); +} + +/** Tests that multiple partition volumes can be added correctly. */ +export function testAddVolumeForMultipleUsbPartitionsGrouping() { + const currentState = getEmptyState(); + // Add USB/partition-1 into the store. + const devicePath = 'device/path/1'; + const partition1 = createFakeVolume({ + volumeId: 'removable:partition1', + volumeType: VolumeManagerCommon.VolumeType.REMOVABLE, + rootKey: 'partition1-url', + label: 'Partition 1', + devicePath, + driveLabel: 'USB_Drive', + }); + currentState.volumes[partition1.volumeId] = partition1; + // Add its parent entry to the store. + const parentEntry = new EntryList( + partition1.driveLabel!, VolumeManagerCommon.RootType.REMOVABLE, + partition1.devicePath); + currentState.allEntries[parentEntry.toURL()] = createFakeFileData({ + entry: parentEntry, + label: partition1.driveLabel!, + type: EntryType.ENTRY_LIST, + }); + + const partition2VolumeInfo = MockVolumeManager.createMockVolumeInfo( + VolumeManagerCommon.VolumeType.REMOVABLE, 'removable:partition2', + 'Partition 2', devicePath); + const partition2VolumeMetadata = createFakeVolumeMetadata({ + volumeType: partition2VolumeInfo.volumeType, + volumeId: partition2VolumeInfo.volumeId, + devicePath: partition1.devicePath, + driveLabel: partition1.driveLabel, + }); + const newState = addVolume(currentState, addVolumeAction({ + volumeInfo: partition2VolumeInfo, + volumeMetadata: partition2VolumeMetadata, + })); + // Check the newly added volume and all existing volumes belonging to the same + // group have prefix key setup. + assertEquals( + parentEntry.toURL(), newState.volumes[partition1.volumeId].prefixKey); + assertEquals( + parentEntry.toURL(), + newState.volumes[partition2VolumeInfo.volumeId].prefixKey); +} + +/** Tests that volume can be removed correctly. */ +export function testRemoveVolume() { + const currentState = getEmptyState(); + const volume = createFakeVolume({ + volumeId: 'test', + volumeType: VolumeManagerCommon.VolumeType.ARCHIVE, + label: 'test.zip', + rootKey: 'test-root', + }); + currentState.volumes[volume.volumeId] = volume; + const newState = removeVolume( + currentState, removeVolumeAction({volumeId: volume.volumeId})); + assertEquals(undefined, newState.volumes[volume.volumeId]); +} diff --git a/ui/file_manager/file_names.gni b/ui/file_manager/file_names.gni index dc9b06c6648b9..73b212729d87b 100644 --- a/ui/file_manager/file_names.gni +++ b/ui/file_manager/file_names.gni @@ -254,16 +254,27 @@ ts_files = [ # Actions. "file_manager/state/actions.ts", "file_manager/state/actions/all_entries.ts", + "file_manager/state/actions/android_apps.ts", "file_manager/state/actions/current_directory.ts", + "file_manager/state/actions/folder_shortcuts.ts", + "file_manager/state/actions/navigation.ts", + "file_manager/state/actions/ui_entries.ts", + "file_manager/state/actions/volumes.ts", # ActionsProducers. + "file_manager/state/actions_producers/all_entries.ts", "file_manager/state/actions_producers/current_directory.ts", # Reducers. "file_manager/state/reducers/root.ts", "file_manager/state/reducers/all_entries.ts", + "file_manager/state/reducers/android_apps.ts", "file_manager/state/reducers/current_directory.ts", + "file_manager/state/reducers/folder_shortcuts.ts", + "file_manager/state/reducers/navigation.ts", "file_manager/state/reducers/search.ts", + "file_manager/state/reducers/ui_entries.ts", + "file_manager/state/reducers/volumes.ts", # Containers. "file_manager/containers/breadcrumb_container.ts", @@ -320,11 +331,19 @@ ts_test_files = [ "file_manager/lib/actions_producer_unittest.ts", "file_manager/lib/for_tests.ts", + # Action producers: + "file_manager/state/actions_producers/all_entries_unittest.ts", + # Reducers: "file_manager/state/for_tests.ts", "file_manager/state/reducers/all_entries_unittest.ts", + "file_manager/state/reducers/android_apps_unittest.ts", "file_manager/state/reducers/current_directory_unittest.ts", + "file_manager/state/reducers/folder_shortcuts_unittest.ts", + "file_manager/state/reducers/navigation_unittest.ts", "file_manager/state/reducers/search_unittest.ts", + "file_manager/state/reducers/ui_entries_unittest.ts", + "file_manager/state/reducers/volumes_unittest.ts", # Widgets: "file_manager/widgets/xf_breadcrumb_unittest.ts",