Use list model for drive view model

Co-authored-by: ivk <ivk@tutao.de>
This commit is contained in:
mab 2025-12-04 17:34:00 +01:00
parent 8641be93c7
commit 6294845462
6 changed files with 264 additions and 215 deletions

View file

@ -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 }
}
/**

View file

@ -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,
}),

View file

@ -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)
},
},
],
}),
),
]),
),
])
}
}

View file

@ -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),
)
}

View file

@ -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 }),
],

View file

@ -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"