mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-08 06:09:50 +00:00
Use list model for drive view model
Co-authored-by: ivk <ivk@tutao.de>
This commit is contained in:
parent
8641be93c7
commit
6294845462
6 changed files with 264 additions and 215 deletions
|
|
@ -41,7 +41,6 @@ export type DriveCryptoInfo = {
|
|||
}
|
||||
|
||||
export interface FolderContents {
|
||||
folder: DriveFolder
|
||||
files: DriveFile[]
|
||||
folders: DriveFolder[]
|
||||
}
|
||||
|
|
@ -125,10 +124,6 @@ export class DriveFacade {
|
|||
}
|
||||
|
||||
public async getFolderContents(folderId: IdTuple): Promise<FolderContents> {
|
||||
const { fileGroupKey } = await this.getCryptoInfo()
|
||||
|
||||
// const data = createDriveGetIn({ folder: folderId })
|
||||
// const driveGetOut = await this.serviceExecutor.get(DriveService, data)
|
||||
const folder = await this.entityClient.load(DriveFolderTypeRef, folderId)
|
||||
const refs = await this.entityClient.loadAll(DriveFileRefTypeRef, folder.files)
|
||||
// FIXME: things can be in different lists but we probably have a helper for that already
|
||||
|
|
@ -144,18 +139,7 @@ export class DriveFacade {
|
|||
this.entityClient,
|
||||
folderRefs.map((ref) => assertNotNull(ref.folder)),
|
||||
)
|
||||
|
||||
// const decryptedNamesAndFiles = parents.map((entry, index) => {
|
||||
// if (index > 0) {
|
||||
// const key = locator.cryptoWrapper.decryptKey(fileGroupKey.object, entry.ownerEncSessionKey)
|
||||
// const currentFolderName = locator.cryptoWrapper.aesDecrypt(key, entry.encName, true)
|
||||
//
|
||||
// return { folderName: utf8Uint8ArrayToString(currentFolderName), folder: entry.folder } as BreadcrumbEntry
|
||||
// }
|
||||
// return { folderName: "Home", folder: entry.folder }
|
||||
// })
|
||||
|
||||
return { folder, files, folders }
|
||||
return { files, folders }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,20 +1,28 @@
|
|||
import m, { Children, Component, Vnode } from "mithril"
|
||||
import { SortColumn, SortingPreference } from "./DriveViewModel"
|
||||
import { FolderItem, SortColumn, SortingPreference } from "./DriveViewModel"
|
||||
import { DriveFolderContentEntry, FileActions } from "./DriveFolderContentEntry"
|
||||
import { DriveSortArrow } from "./DriveSortArrow"
|
||||
import { lang, Translation } from "../../../common/misc/LanguageViewModel"
|
||||
import { px, size } from "../../../common/gui/size"
|
||||
import { SelectableFolderItem } from "./DriveView"
|
||||
import { ListState } from "../../../common/gui/base/List"
|
||||
|
||||
export type SelectionState = { type: "multiselect"; selectedItemCount: number; selectedAll: boolean } | { type: "none" }
|
||||
|
||||
export interface DriveFolderSelectionEvents {
|
||||
onSingleExclusiveSelection: (item: FolderItem) => unknown
|
||||
onSingleInclusiveSelection: (item: FolderItem) => unknown
|
||||
onSelectPrevious: (item: FolderItem) => unknown
|
||||
onSelectNext: (item: FolderItem) => unknown
|
||||
onSelectAll: () => unknown
|
||||
}
|
||||
|
||||
export interface DriveFolderContentAttrs {
|
||||
selection: SelectionState
|
||||
items: readonly SelectableFolderItem[]
|
||||
sortOrder: SortingPreference
|
||||
fileActions: FileActions
|
||||
onSort: (column: SortColumn) => unknown
|
||||
onSelectAll: () => unknown
|
||||
listState: ListState<FolderItem>
|
||||
selectionEvents: DriveFolderSelectionEvents
|
||||
}
|
||||
|
||||
const columnStyle = {
|
||||
|
|
@ -47,7 +55,7 @@ function renderHeaderCell(
|
|||
}
|
||||
|
||||
export class DriveFolderContent implements Component<DriveFolderContentAttrs> {
|
||||
view({ attrs: { selection, sortOrder, onSort, items, fileActions, onSelectAll } }: Vnode<DriveFolderContentAttrs>): Children {
|
||||
view({ attrs: { selection, sortOrder, onSort, fileActions, selectionEvents, listState } }: Vnode<DriveFolderContentAttrs>): Children {
|
||||
return m(
|
||||
"div.flex.col.overflow-hidden",
|
||||
{
|
||||
|
|
@ -58,7 +66,7 @@ export class DriveFolderContent implements Component<DriveFolderContentAttrs> {
|
|||
},
|
||||
},
|
||||
[
|
||||
this.renderHeader(selection, sortOrder, onSort, onSelectAll),
|
||||
this.renderHeader(selection, sortOrder, onSort, selectionEvents.onSelectAll),
|
||||
|
||||
m(
|
||||
".flex.col.scroll.scrollbar-gutter-stable-or-fallback",
|
||||
|
|
@ -70,10 +78,12 @@ export class DriveFolderContent implements Component<DriveFolderContentAttrs> {
|
|||
"grid-template-columns": "subgrid",
|
||||
},
|
||||
},
|
||||
items.map((item) =>
|
||||
listState.items.map((item) =>
|
||||
// FIXME: give them an id
|
||||
m(DriveFolderContentEntry, {
|
||||
item: item,
|
||||
onSelect: (f) => {},
|
||||
selected: listState.selectedItems.has(item),
|
||||
onSelect: selectionEvents.onSingleInclusiveSelection,
|
||||
checked: false,
|
||||
fileActions,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ import { formatStorageSize } from "../../../common/misc/Formatter"
|
|||
import { FolderItem } from "./DriveViewModel"
|
||||
import { Icon, IconSize } from "../../../common/gui/base/Icon"
|
||||
import { Icons } from "../../../common/gui/base/icons/Icons"
|
||||
import { DriveFile } from "../../../common/api/entities/drive/TypeRefs"
|
||||
import { filterInt } from "@tutao/tutanota-utils"
|
||||
import { IconButton } from "../../../common/gui/base/IconButton"
|
||||
import { attachDropdown } from "../../../common/gui/base/Dropdown"
|
||||
import { theme } from "../../../common/gui/theme"
|
||||
import { SelectableFolderItem } from "./DriveView"
|
||||
|
||||
export interface FileActions {
|
||||
onCut: (f: FolderItem) => unknown
|
||||
|
|
@ -18,12 +16,12 @@ export interface FileActions {
|
|||
onDelete: (f: FolderItem) => unknown
|
||||
onRename: (f: FolderItem) => unknown
|
||||
onRestore: (f: FolderItem) => unknown
|
||||
onSelect: (f: FolderItem) => unknown
|
||||
}
|
||||
|
||||
export interface DriveFolderContentEntryAttrs {
|
||||
item: SelectableFolderItem
|
||||
onSelect: (f: DriveFile) => void
|
||||
item: FolderItem
|
||||
selected: boolean
|
||||
onSelect: (f: FolderItem) => unknown
|
||||
checked: boolean // maybe should be inside a map inside the model
|
||||
fileActions: FileActions
|
||||
}
|
||||
|
|
@ -74,7 +72,9 @@ export class DriveFolderContentEntry implements Component<DriveFolderContentEntr
|
|||
view({
|
||||
attrs: {
|
||||
item,
|
||||
fileActions: { onCopy, onCut, onDelete, onRestore, onOpenItem, onRename, onSelect },
|
||||
selected,
|
||||
onSelect,
|
||||
fileActions: { onCopy, onCut, onDelete, onRestore, onOpenItem, onRename },
|
||||
},
|
||||
}: Vnode<DriveFolderContentEntryAttrs>): Children {
|
||||
const uploadDate = item.type === "file" ? item.file.createdDate : item.folder.createdDate
|
||||
|
|
@ -83,90 +83,86 @@ export class DriveFolderContentEntry implements Component<DriveFolderContentEntr
|
|||
|
||||
const thisFileMimeType = item.type === "file" ? mimeTypeAsText(item.file.mimeType) : "Folder"
|
||||
|
||||
return m(
|
||||
"div.flex.row.folder-row",
|
||||
{ style: { ...DriveFolderContentEntryRowStyle, background: item.selected ? theme.state_bg_hover : theme.surface } },
|
||||
[
|
||||
m("div", {}, m("input.checkbox", { type: "checkbox", checked: item.selected, onchange: () => onSelect(item) })),
|
||||
return m("div.flex.row.folder-row", { style: { ...DriveFolderContentEntryRowStyle, background: selected ? theme.state_bg_hover : theme.surface } }, [
|
||||
m("div", {}, m("input.checkbox", { type: "checkbox", checked: selected, onchange: () => onSelect(item) })),
|
||||
m(
|
||||
"div",
|
||||
{ style: {} },
|
||||
m(Icon, {
|
||||
icon: thisFileIsAFolder ? Icons.Folder : iconPerMimeType(item.file.mimeType),
|
||||
size: IconSize.Medium,
|
||||
style: { fill: theme.on_surface, display: "block", margin: "0 auto" },
|
||||
}),
|
||||
),
|
||||
m(
|
||||
"div",
|
||||
{ style: {} },
|
||||
m(
|
||||
"div",
|
||||
{ style: {} },
|
||||
m(Icon, {
|
||||
icon: thisFileIsAFolder ? Icons.Folder : iconPerMimeType(item.file.mimeType),
|
||||
size: IconSize.Medium,
|
||||
style: { fill: theme.on_surface, display: "block", margin: "0 auto" },
|
||||
}),
|
||||
),
|
||||
m(
|
||||
"div",
|
||||
{ style: {} },
|
||||
m(
|
||||
"span",
|
||||
{
|
||||
onclick: () => {
|
||||
onOpenItem(item)
|
||||
},
|
||||
class: "cursor-pointer",
|
||||
"span",
|
||||
{
|
||||
onclick: () => {
|
||||
onOpenItem(item)
|
||||
},
|
||||
item.type === "file" ? item.file.name : item.folder.name,
|
||||
),
|
||||
class: "cursor-pointer",
|
||||
},
|
||||
item.type === "file" ? item.file.name : item.folder.name,
|
||||
),
|
||||
m("div", { style: {} }, thisFileMimeType),
|
||||
m("div", { style: {} }, item.type === "folder" ? "🐱" : formatStorageSize(filterInt(item.file.size))),
|
||||
m("div", { style: {} }, uploadDate.toLocaleString()),
|
||||
m(
|
||||
"div",
|
||||
m("div", [
|
||||
m(
|
||||
IconButton,
|
||||
attachDropdown({
|
||||
mainButtonAttrs: {
|
||||
icon: Icons.More,
|
||||
title: "more_label",
|
||||
),
|
||||
m("div", { style: {} }, thisFileMimeType),
|
||||
m("div", { style: {} }, item.type === "folder" ? "🐱" : formatStorageSize(filterInt(item.file.size))),
|
||||
m("div", { style: {} }, uploadDate.toLocaleString()),
|
||||
m(
|
||||
"div",
|
||||
m("div", [
|
||||
m(
|
||||
IconButton,
|
||||
attachDropdown({
|
||||
mainButtonAttrs: {
|
||||
icon: Icons.More,
|
||||
title: "more_label",
|
||||
},
|
||||
childAttrs: () => [
|
||||
{
|
||||
label: "rename_action",
|
||||
icon: Icons.Edit,
|
||||
click: () => {
|
||||
onRename(item)
|
||||
},
|
||||
},
|
||||
childAttrs: () => [
|
||||
{
|
||||
label: "rename_action",
|
||||
icon: Icons.Edit,
|
||||
click: () => {
|
||||
onRename(item)
|
||||
},
|
||||
{
|
||||
label: "copy_action",
|
||||
icon: Icons.Copy,
|
||||
click: () => {
|
||||
onCopy(item)
|
||||
},
|
||||
{
|
||||
label: "copy_action",
|
||||
icon: Icons.Copy,
|
||||
click: () => {
|
||||
onCopy(item)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "cut_action",
|
||||
icon: Icons.Cut,
|
||||
click: () => {
|
||||
onCut(item)
|
||||
},
|
||||
{
|
||||
label: "cut_action",
|
||||
icon: Icons.Cut,
|
||||
click: () => {
|
||||
onCut(item)
|
||||
},
|
||||
},
|
||||
(item.type === "file" && item.file.originalParent != null) || (item.type === "folder" && item.folder.originalParent != null)
|
||||
? {
|
||||
label: "restoreFromTrash_action",
|
||||
icon: Icons.Reply,
|
||||
click: () => {
|
||||
onRestore(item)
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: "trash_action",
|
||||
icon: Icons.Trash,
|
||||
click: () => {
|
||||
onDelete(item)
|
||||
},
|
||||
},
|
||||
(item.type === "file" && item.file.originalParent != null) || (item.type === "folder" && item.folder.originalParent != null)
|
||||
? {
|
||||
label: "restoreFromTrash_action",
|
||||
icon: Icons.Reply,
|
||||
click: () => {
|
||||
onRestore(item)
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
: {
|
||||
label: "trash_action",
|
||||
icon: Icons.Trash,
|
||||
click: () => {
|
||||
onDelete(item)
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
import m, { Children, Component, Vnode } from "mithril"
|
||||
import { DriveViewModel } from "./DriveViewModel"
|
||||
import { DriveViewModel, FolderItem } from "./DriveViewModel"
|
||||
import { DriveFolderNav } from "./DriveFolderNav"
|
||||
import { DriveFolderContent, DriveFolderContentAttrs, SelectionState } from "./DriveFolderContent"
|
||||
import { DriveFolderContent, DriveFolderContentAttrs, DriveFolderSelectionEvents, SelectionState } from "./DriveFolderContent"
|
||||
import { DriveFolder } from "../../../common/api/entities/drive/TypeRefs"
|
||||
import { Dialog } from "../../../common/gui/base/Dialog"
|
||||
import { lang } from "../../../common/misc/LanguageViewModel"
|
||||
import { SelectableFolderItem } from "./DriveView"
|
||||
import { ListState } from "../../../common/gui/base/List"
|
||||
|
||||
export interface DriveFolderViewAttrs {
|
||||
onUploadClick: (dom: HTMLElement) => void
|
||||
items: readonly SelectableFolderItem[]
|
||||
selection: SelectionState
|
||||
onSelectAll: () => unknown
|
||||
onPaste: (() => unknown) | null
|
||||
driveViewModel: DriveViewModel
|
||||
currentFolder: DriveFolder | null
|
||||
parents: readonly DriveFolder[]
|
||||
listState: ListState<FolderItem>
|
||||
selectionEvents: DriveFolderSelectionEvents
|
||||
}
|
||||
|
||||
export class DriveFolderView implements Component<DriveFolderViewAttrs> {
|
||||
view({ attrs: { driveViewModel, items, onPaste, onUploadClick, currentFolder, parents, selection, onSelectAll } }: Vnode<DriveFolderViewAttrs>): Children {
|
||||
view({
|
||||
attrs: { driveViewModel, onPaste, onUploadClick, currentFolder, parents, selection, selectionEvents, listState },
|
||||
}: Vnode<DriveFolderViewAttrs>): Children {
|
||||
return m(
|
||||
"div.col.flex.plr-button.fill-absolute",
|
||||
{ style: { gap: "15px" } },
|
||||
|
|
@ -33,7 +35,6 @@ export class DriveFolderView implements Component<DriveFolderViewAttrs> {
|
|||
},
|
||||
}),
|
||||
m(DriveFolderContent, {
|
||||
items: items,
|
||||
sortOrder: driveViewModel.getCurrentColumnSortOrder(),
|
||||
fileActions: {
|
||||
onOpenItem: (item) => {
|
||||
|
|
@ -67,15 +68,13 @@ export class DriveFolderView implements Component<DriveFolderViewAttrs> {
|
|||
},
|
||||
)
|
||||
},
|
||||
onSelect: (item) => {
|
||||
driveViewModel.onSingleSelection(item)
|
||||
},
|
||||
},
|
||||
onSort: (newSortingOrder) => {
|
||||
driveViewModel.sort(newSortingOrder)
|
||||
},
|
||||
onSelectAll,
|
||||
selection,
|
||||
listState,
|
||||
selectionEvents,
|
||||
} satisfies DriveFolderContentAttrs),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { TopLevelAttrs, TopLevelView } from "../../../TopLevelView"
|
|||
import { DrawerMenuAttrs } from "../../../common/gui/nav/DrawerMenu"
|
||||
import { AppHeaderAttrs, Header } from "../../../common/gui/Header"
|
||||
import m, { Children, Vnode } from "mithril"
|
||||
import { DriveFolderType, DriveViewModel, FolderItem, folderItemEntity } from "./DriveViewModel"
|
||||
import { DriveFolderType, DriveViewModel, FolderItem } from "./DriveViewModel"
|
||||
import { BaseTopLevelView } from "../../../common/gui/BaseTopLevelView"
|
||||
import { DataFile } from "../../../common/api/common/DataFile"
|
||||
import { FileReference } from "../../../common/api/common/utils/FileUtils"
|
||||
|
|
@ -23,7 +23,6 @@ import { ChunkedUploadInfo } from "../../../common/api/common/drive/DriveTypes"
|
|||
import { showStandardsFileChooser } from "../../../common/file/FileController"
|
||||
import { ProgrammingError } from "../../../common/api/common/error/ProgrammingError"
|
||||
import { renderSidebarFolders } from "./Sidebar"
|
||||
import { getElementId } from "../../../common/api/common/utils/EntityUtils"
|
||||
|
||||
export interface DriveViewAttrs extends TopLevelAttrs {
|
||||
drawerAttrs: DrawerMenuAttrs
|
||||
|
|
@ -62,7 +61,7 @@ export class DriveView extends BaseTopLevelView implements TopLevelView<DriveVie
|
|||
const { folderListId, folderElementId } = args as { folderListId: string; folderElementId: string }
|
||||
|
||||
if (folderListId && folderElementId) {
|
||||
this.driveViewModel.loadFolderContentsByIdTuple([folderListId, folderElementId]).then(() => m.redraw())
|
||||
this.driveViewModel.loadFolder([folderListId, folderElementId]).then(() => m.redraw())
|
||||
} else {
|
||||
// /drive
|
||||
// No folder given, load the drive root
|
||||
|
|
@ -164,7 +163,7 @@ export class DriveView extends BaseTopLevelView implements TopLevelView<DriveVie
|
|||
return new ViewColumn(
|
||||
{
|
||||
view: () => {
|
||||
const selectedItems = this.driveViewModel.selectedItems
|
||||
const listState = this.driveViewModel.listState()
|
||||
return m(
|
||||
BackgroundColumnLayout,
|
||||
{
|
||||
|
|
@ -173,29 +172,29 @@ export class DriveView extends BaseTopLevelView implements TopLevelView<DriveVie
|
|||
columnLayout: [
|
||||
m(DriveFolderView, {
|
||||
onUploadClick: this.onNewFile_Click,
|
||||
items:
|
||||
this.driveViewModel.currentFolder == null
|
||||
? []
|
||||
: this.driveViewModel.currentFolder.items.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
selected: selectedItems.has(getElementId(folderItemEntity(item))),
|
||||
}
|
||||
}),
|
||||
driveViewModel: this.driveViewModel,
|
||||
onPaste: this.driveViewModel.clipboard ? () => this.driveViewModel.paste() : null,
|
||||
currentFolder: this.driveViewModel.currentFolder?.folder ?? null,
|
||||
parents: this.driveViewModel.parents,
|
||||
onSelectAll: () => this.driveViewModel.onSelectAll(),
|
||||
selection:
|
||||
// FIXME there should be a real multiselect state maybe?
|
||||
selectedItems.size === 0
|
||||
? { type: "none" }
|
||||
: {
|
||||
type: "multiselect",
|
||||
selectedItemCount: selectedItems.size,
|
||||
selectedAll: selectedItems.size === this.driveViewModel.currentFolder?.items.length,
|
||||
},
|
||||
selection: listState.inMultiselect
|
||||
? {
|
||||
type: "multiselect",
|
||||
selectedAll: this.driveViewModel.areAllSelected(),
|
||||
selectedItemCount: listState.selectedItems.size,
|
||||
}
|
||||
: { type: "none" },
|
||||
listState: listState,
|
||||
selectionEvents: {
|
||||
onSelectAll: () => {
|
||||
this.driveViewModel.onSelectAll()
|
||||
},
|
||||
onSelectNext: () => {},
|
||||
onSelectPrevious: () => {},
|
||||
onSingleInclusiveSelection: (item) => {
|
||||
this.driveViewModel.onSingleInclusiveSelection(item)
|
||||
},
|
||||
onSingleExclusiveSelection: () => {},
|
||||
},
|
||||
} satisfies DriveFolderViewAttrs),
|
||||
m(DriveUploadStack, { model: this.driveViewModel.driveUploadStackModel }),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { EntityClient } from "../../../common/api/common/EntityClient"
|
||||
import { BreadcrumbEntry, DriveFacade, UploadGuid } from "../../../common/api/worker/facades/DriveFacade"
|
||||
import { Router } from "../../../common/gui/ScopedRouter"
|
||||
import { elementIdPart, getElementId, listIdPart } from "../../../common/api/common/utils/EntityUtils"
|
||||
import { elementIdPart, getElementId, isSameId, listIdPart } from "../../../common/api/common/utils/EntityUtils"
|
||||
import m from "mithril"
|
||||
import { NotFoundError } from "../../../common/api/common/error/RestError"
|
||||
import { locator } from "../../../common/api/main/CommonLocator"
|
||||
import { assertNotNull } from "@tutao/tutanota-utils"
|
||||
import { assertNotNull, memoizedWithHiddenArgument } from "@tutao/tutanota-utils"
|
||||
import { UploadProgressListener } from "../../../common/api/main/UploadProgressListener"
|
||||
import { DriveUploadStackModel } from "./DriveUploadStackModel"
|
||||
import { getDefaultSenderFromUser } from "../../../common/mailFunctionality/SharedMailUtils"
|
||||
|
|
@ -13,6 +13,11 @@ import { DriveFile, DriveFileRefTypeRef, DriveFileTypeRef, DriveFolder, DriveFol
|
|||
import { EventController } from "../../../common/api/main/EventController"
|
||||
import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils"
|
||||
import { ArchiveDataType, OperationType } from "../../../common/api/common/TutanotaConstants"
|
||||
import { ListModel } from "../../../common/misc/ListModel"
|
||||
import { ListAutoSelectBehavior } from "../../../common/misc/DeviceConfig"
|
||||
import { ListFetchResult } from "../../../common/gui/base/ListUtils"
|
||||
import { ListState } from "../../../common/gui/base/List"
|
||||
import Stream from "mithril/stream"
|
||||
|
||||
export const enum DriveFolderType {
|
||||
Regular = "0",
|
||||
|
|
@ -34,7 +39,6 @@ export type FolderItem = FileFolderItem | FolderFolderItem
|
|||
|
||||
export interface RegularFolder {
|
||||
type: DriveFolderType.Regular
|
||||
items: readonly FolderItem[]
|
||||
parents: readonly BreadcrumbEntry[]
|
||||
folder: DriveFolder
|
||||
}
|
||||
|
|
@ -43,7 +47,6 @@ export type SpecialFolderType = DriveFolderType.Root | DriveFolderType.Trash
|
|||
|
||||
export interface SpecialFolder {
|
||||
type: SpecialFolderType
|
||||
items: readonly FolderItem[]
|
||||
folder: DriveFolder
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +101,30 @@ export function folderItemEntity(folderItem: FileFolderItem | FolderFolderItem):
|
|||
|
||||
type DriveClipboard = { item: FolderItem; action: ClipboardAction }
|
||||
|
||||
function emptyListModel<Item, Id>(): ListModel<Item, Id> {
|
||||
return new ListModel({
|
||||
async fetch(): Promise<ListFetchResult<Item>> {
|
||||
return { items: [], complete: true }
|
||||
},
|
||||
getItemId(item: Item): Id {
|
||||
throw new Error("Should not be called")
|
||||
},
|
||||
isSameId(id1: Id, id2: Id): boolean {
|
||||
throw new Error("Should not be called")
|
||||
},
|
||||
sortCompare(item1: Item, item2: Item): number {
|
||||
throw new Error("Should not be called")
|
||||
},
|
||||
autoSelectBehavior: () => ListAutoSelectBehavior.NONE,
|
||||
})
|
||||
}
|
||||
|
||||
type ComparisonFunction = (f1: FolderItem, f2: FolderItem) => number
|
||||
export class DriveViewModel {
|
||||
public readonly driveUploadStackModel: DriveUploadStackModel
|
||||
public readonly userMailAddress: string
|
||||
|
||||
private sortingPreference: SortingPreference = { order: "asc", column: SortColumn.name }
|
||||
private sortingPreference: Readonly<SortingPreference> = { order: "asc", column: SortColumn.name }
|
||||
|
||||
// normal folder view
|
||||
currentFolder: DisplayFolder | null = null
|
||||
|
|
@ -111,12 +133,14 @@ export class DriveViewModel {
|
|||
roots!: Awaited<ReturnType<DriveFacade["loadRootFolders"]>>
|
||||
|
||||
private _clipboard: DriveClipboard | null = null
|
||||
readonly selectedItems: Set<Id> = new Set()
|
||||
|
||||
get clipboard(): DriveClipboard | null {
|
||||
return this._clipboard
|
||||
}
|
||||
|
||||
private listModel: ListModel<FolderItem, Id> = emptyListModel()
|
||||
private listStateSubscription: Stream<unknown> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly entityClient: EntityClient,
|
||||
private readonly driveFacade: DriveFacade,
|
||||
|
|
@ -136,27 +160,89 @@ export class DriveViewModel {
|
|||
this.roots = await this.driveFacade.loadRootFolders()
|
||||
}
|
||||
|
||||
private newListModel(folder: DriveFolder): ListModel<FolderItem, Id> {
|
||||
const newListModel = new ListModel<FolderItem, Id>({
|
||||
fetch: async (lastFetchedItem, count) => {
|
||||
if (lastFetchedItem == null) {
|
||||
return { items: await this.loadFolderContents(folder), complete: true }
|
||||
} else {
|
||||
return { items: [] satisfies FolderItem[], complete: true }
|
||||
}
|
||||
},
|
||||
getItemId(item: FolderItem): Id {
|
||||
return getElementId(folderItemEntity(item))
|
||||
},
|
||||
sortCompare: (item1: FolderItem, item2: FolderItem): number => {
|
||||
return this.comparisonFunction()(item1, item2)
|
||||
},
|
||||
isSameId: isSameId,
|
||||
autoSelectBehavior: () => ListAutoSelectBehavior.OLDER,
|
||||
})
|
||||
this.listStateSubscription?.end(true)
|
||||
this.listStateSubscription = newListModel.stateStream.map(this.updateUi)
|
||||
return newListModel
|
||||
}
|
||||
|
||||
private readonly comparisonFunction: () => ComparisonFunction = memoizedWithHiddenArgument(
|
||||
() => this.sortingPreference,
|
||||
() => {
|
||||
const column = this.sortingPreference.column
|
||||
const itemName = (item: FolderItem) => (item.type === "folder" ? item.folder.name : item.file.name)
|
||||
const itemDate = (item: FolderItem) => (item.type === "folder" ? item.folder.updatedDate : item.file.updatedDate)
|
||||
const itemSize = (item: FolderItem) => (item.type === "folder" ? 0n : BigInt(item.file.size))
|
||||
|
||||
const itemMimeType = (item: FolderItem) => (item.type === "folder" ? "" : item.file.mimeType)
|
||||
|
||||
const attrToComparisonFunction: Record<SortColumn, ComparisonFunction> = {
|
||||
name: (f1: FolderItem, f2: FolderItem) => compareString(itemName(f1), itemName(f2)),
|
||||
mimeType: (f1: FolderItem, f2: FolderItem) => compareString(itemMimeType(f1), itemMimeType(f2)),
|
||||
size: (f1: FolderItem, f2: FolderItem) => compareNumber(itemSize(f1), itemSize(f2)),
|
||||
date: (f1: FolderItem, f2: FolderItem) => compareNumber(itemDate(f1).getTime(), itemDate(f2).getTime()),
|
||||
}
|
||||
|
||||
const comparisonFn = attrToComparisonFunction[column]
|
||||
|
||||
// invert comparison function when the order is descending
|
||||
const sortFunction: typeof comparisonFn = this.sortingPreference.order === "asc" ? comparisonFn : (l, r) => -comparisonFn(l, r)
|
||||
return sortFunction
|
||||
},
|
||||
)
|
||||
|
||||
private async entityEventsReceived(events: ReadonlyArray<EntityUpdateData>) {
|
||||
for (const update of events) {
|
||||
if (isUpdateForTypeRef(DriveFileRefTypeRef, update) && update.instanceListId === this.currentFolder?.folder.files) {
|
||||
if (update.operation === OperationType.DELETE) {
|
||||
// FileRef has the same element id as the item it points to
|
||||
this.selectedItems.delete(update.instanceId)
|
||||
await this.listModel.deleteLoadedItem(update.instanceId)
|
||||
}
|
||||
if (update.operation === OperationType.CREATE) {
|
||||
const fileRef = await this.entityClient.load(DriveFileRefTypeRef, [update.instanceListId, update.instanceId])
|
||||
const item = fileRef.file ? await this.loadItem("file", fileRef.file) : await this.loadItem("folder", assertNotNull(fileRef.folder))
|
||||
this.listModel.waitLoad(() => {
|
||||
if (this.listModel.canInsertItem(item)) {
|
||||
this.listModel.insertLoadedItem(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
await this.loadFolderContentsByIdTuple(this.currentFolder.folder._id)
|
||||
this.updateUi()
|
||||
} else if (isUpdateForTypeRef(DriveFileTypeRef, update) || isUpdateForTypeRef(DriveFolderTypeRef, update)) {
|
||||
if (this.currentFolder == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// FIXME: Do not reload the whole folder for this kind of update.
|
||||
await this.loadFolderContentsByIdTuple(this.currentFolder.folder._id)
|
||||
this.updateUi()
|
||||
const item = await this.loadItem(isUpdateForTypeRef(DriveFolderTypeRef, update) ? "folder" : "file", [update.instanceListId, update.instanceId])
|
||||
this.listModel.updateLoadedItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadItem(type: "file" | "folder", id: IdTuple): Promise<FolderItem> {
|
||||
if (type === "file") {
|
||||
const file = await this.entityClient.load(DriveFileTypeRef, id)
|
||||
return { type, file }
|
||||
} else {
|
||||
const folder = await this.entityClient.load(DriveFolderTypeRef, id)
|
||||
return { type, folder }
|
||||
}
|
||||
}
|
||||
|
||||
cut(item: FolderItem) {
|
||||
this._clipboard = { item, action: ClipboardAction.Cut }
|
||||
}
|
||||
|
|
@ -192,22 +278,21 @@ export class DriveViewModel {
|
|||
}
|
||||
|
||||
async loadSpecialFolder(specialFolderType: SpecialFolderType) {
|
||||
let folderWithItems: FolderWithItems
|
||||
let folder: DriveFolder
|
||||
switch (specialFolderType) {
|
||||
case DriveFolderType.Trash:
|
||||
folderWithItems = await this.getFolderItems(this.roots.trash)
|
||||
folder = await this.entityClient.load(DriveFolderTypeRef, this.roots.trash)
|
||||
break
|
||||
case DriveFolderType.Root:
|
||||
folderWithItems = await this.getFolderItems(this.roots.root)
|
||||
folder = await this.entityClient.load(DriveFolderTypeRef, this.roots.root)
|
||||
break
|
||||
}
|
||||
|
||||
this.currentFolder = {
|
||||
folder: folderWithItems.folder,
|
||||
items: folderWithItems.items,
|
||||
folder: folder,
|
||||
type: specialFolderType,
|
||||
}
|
||||
await this.loadParents(folderWithItems.folder)
|
||||
await this.loadParents(folder)
|
||||
}
|
||||
|
||||
private async loadParents(folder: DriveFolder) {
|
||||
|
|
@ -220,25 +305,16 @@ export class DriveViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
private async getFolderItems(folderId: IdTuple): Promise<FolderWithItems> {
|
||||
const { folder, files, folders } = await this.driveFacade.getFolderContents(folderId)
|
||||
const items = [
|
||||
...folders.map((folder) => ({ type: "folder", folder }) satisfies FolderFolderItem),
|
||||
...files.map((file) => ({ type: "file", file }) satisfies FileFolderItem),
|
||||
]
|
||||
return { folder, items }
|
||||
}
|
||||
|
||||
async loadFolderContentsByIdTuple(idTuple: IdTuple): Promise<void> {
|
||||
async loadFolder(folderId: IdTuple): Promise<void> {
|
||||
try {
|
||||
const { folder, items } = await this.getFolderItems(idTuple)
|
||||
|
||||
const folder = await this.entityClient.load(DriveFolderTypeRef, folderId)
|
||||
this.currentFolder = {
|
||||
type: DriveFolderType.Regular,
|
||||
folder,
|
||||
items,
|
||||
parents: [],
|
||||
} satisfies RegularFolder
|
||||
this.listModel = this.newListModel(folder)
|
||||
this.listModel.loadInitial()
|
||||
await this.loadParents(folder)
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
|
|
@ -249,6 +325,15 @@ export class DriveViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
async loadFolderContents(folder: DriveFolder): Promise<FolderItem[]> {
|
||||
const { files, folders } = await this.driveFacade.getFolderContents(folder._id)
|
||||
const items = [
|
||||
...folders.map((folder) => ({ type: "folder", folder }) satisfies FolderFolderItem),
|
||||
...files.map((file) => ({ type: "file", file }) satisfies FileFolderItem),
|
||||
]
|
||||
return items
|
||||
}
|
||||
|
||||
public generateUploadGuid(): UploadGuid {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
|
@ -289,7 +374,7 @@ export class DriveViewModel {
|
|||
return this.sortingPreference
|
||||
}
|
||||
|
||||
sort(column: SortColumn, folderFirst: boolean = false) {
|
||||
sort(column: SortColumn) {
|
||||
if (this.sortingPreference.column === column) {
|
||||
// flip order
|
||||
this.sortingPreference = { column: column, order: this.sortingPreference.order === "asc" ? "desc" : "asc" }
|
||||
|
|
@ -298,56 +383,32 @@ export class DriveViewModel {
|
|||
}
|
||||
|
||||
if (this.currentFolder == null) return
|
||||
|
||||
const itemName = (item: FolderItem) => (item.type === "folder" ? item.folder.name : item.file.name)
|
||||
const itemDate = (item: FolderItem) => (item.type === "folder" ? item.folder.updatedDate : item.file.updatedDate)
|
||||
const itemSize = (item: FolderItem) => (item.type === "folder" ? 0n : BigInt(item.file.size))
|
||||
|
||||
const itemMimeType = (item: FolderItem) => (item.type === "folder" ? "" : item.file.mimeType)
|
||||
|
||||
const attrToComparisonFunction: Record<SortColumn, (f1: FolderItem, f2: FolderItem) => number> = {
|
||||
name: (f1: FolderItem, f2: FolderItem) => compareString(itemName(f1), itemName(f2)),
|
||||
mimeType: (f1: FolderItem, f2: FolderItem) => compareString(itemMimeType(f1), itemMimeType(f2)),
|
||||
size: (f1: FolderItem, f2: FolderItem) => compareNumber(itemSize(f1), itemSize(f2)),
|
||||
date: (f1: FolderItem, f2: FolderItem) => compareNumber(itemDate(f1).getTime(), itemDate(f2).getTime()),
|
||||
}
|
||||
|
||||
const comparisonFn = attrToComparisonFunction[column]
|
||||
|
||||
// invert comparison function when the order is descending
|
||||
const sortFunction: typeof comparisonFn = this.sortingPreference.order === "asc" ? comparisonFn : (l, r) => -comparisonFn(l, r)
|
||||
|
||||
this.currentFolder.items = this.currentFolder.items.toSorted(sortFunction)
|
||||
|
||||
// FIXME
|
||||
// if (folderFirst) {
|
||||
// this.currentFolder.files.sort(sortFoldersFirst)
|
||||
// }
|
||||
this.listModel.sort()
|
||||
}
|
||||
|
||||
rename(item: FolderItem, newName: string) {
|
||||
this.driveFacade.rename(folderItemEntity(item), newName)
|
||||
}
|
||||
|
||||
onSingleSelection(item: FolderItem) {
|
||||
const id = getElementId(folderItemEntity(item))
|
||||
if (this.selectedItems.has(id)) {
|
||||
this.selectedItems.delete(id)
|
||||
} else {
|
||||
this.selectedItems.add(id)
|
||||
}
|
||||
onSingleInclusiveSelection(item: FolderItem) {
|
||||
this.listModel.onSingleInclusiveSelection(item)
|
||||
}
|
||||
|
||||
areAllSelected(): boolean {
|
||||
return this.listModel.areAllSelected()
|
||||
}
|
||||
|
||||
onSelectAll() {
|
||||
const selectedItems = this.selectedItems
|
||||
if (selectedItems.size === this.currentFolder?.items.length) {
|
||||
this.selectedItems.clear()
|
||||
if (this.areAllSelected()) {
|
||||
this.listModel.selectNone()
|
||||
} else {
|
||||
for (const item of this.currentFolder?.items ?? []) {
|
||||
this.selectedItems.add(getElementId(folderItemEntity(item)))
|
||||
}
|
||||
this.listModel.selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
listState(): ListState<FolderItem> {
|
||||
return this.listModel.state
|
||||
}
|
||||
}
|
||||
|
||||
export type SortOrder = "asc" | "desc"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue