feat: implement kryos upload sessions (#6145)

* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
This commit is contained in:
Calum H.
2026-05-21 17:49:48 +01:00
committed by GitHub
parent 2f95c4c441
commit 6e7835fb35
18 changed files with 455 additions and 109 deletions
+2
View File
@@ -11,6 +11,7 @@ import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
@@ -71,6 +72,7 @@ export const MODULE_REGISTRY = {
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
@@ -14,6 +14,7 @@ export class KyrosContentV1Module extends AbstractModule {
* @param files - Files to upload as addons
* @param options - Optional progress callback
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
*/
public uploadAddonFile(
worldId: string,
@@ -94,6 +94,7 @@ export class KyrosFilesV0Module extends AbstractModule {
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
*/
public uploadFile(
path: string,
@@ -1,4 +1,32 @@
export namespace Kyros {
export namespace UploadSessions {
export namespace v1 {
export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active'
| 'uploading'
| 'finalizing'
| 'cancelled'
| 'finalized'
| 'expired'
export interface UploadSessionResponse {
upload_id: string
status: UploadSessionStatus
created_at: number
updated_at: number
last_upload_at: number | null
expires_at: number
entry_count: number
uploaded_byte_count: number
}
export interface GetUploadSessionResponse {
session: UploadSessionResponse | null
}
}
}
export namespace Files {
export namespace v0 {
export interface DirectoryItem {
@@ -0,0 +1,104 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
}
public async create(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async get(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'GET',
useNodeAuth: true,
},
)
}
public uploadFiles(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
files: UploadSessionFile[],
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
const formData = new FormData()
for (const { file, filename } of files) {
formData.append('file', file, filename)
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
{
api: '',
version: 'v1',
formData,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
},
)
}
public async finalize(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async cancel(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
{
api: '',
version: 'v1',
method: 'DELETE',
useNodeAuth: true,
},
)
}
}
@@ -92,6 +92,7 @@ const filesBusyHeader = computed(() =>
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const uploadCancelling = ref(false)
const dismissedContentErrorKey = ref<string | null>(null)
const contentErrorKey = computed(() =>
@@ -327,6 +328,21 @@ async function onBackupRetry(item: BackupAdmonitionEntry) {
await invalidate()
}
async function onUploadCancel() {
if (uploadCancelling.value) return
const cancel = ctx.cancelUpload.value
if (!cancel) return
uploadCancelling.value = true
try {
await cancel()
} catch (err) {
console.error('Failed to cancel upload', err)
} finally {
uploadCancelling.value = false
}
}
async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
@@ -375,7 +391,12 @@ function onContentErrorDismiss() {
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
<UploadAdmonition v-else-if="item.kind === 'upload'" />
<UploadAdmonition
v-else-if="item.kind === 'upload'"
:cancelable="!!ctx.cancelUpload.value"
:cancelling="uploadCancelling"
@cancel="onUploadCancel"
/>
<FileOperationAdmonition
v-else-if="item.kind === 'fs-op'"
:op="item.op"
@@ -15,9 +15,11 @@
Math.round(overallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<template v-if="cancelable" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
Cancel
</button>
</ButtonStyled>
</template>
</Admonition>
@@ -32,12 +34,26 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext } from '#ui/providers'
withDefaults(
defineProps<{
cancelable?: boolean
cancelling?: boolean
}>(),
{
cancelable: true,
cancelling: false,
},
)
defineEmits<{
cancel: []
}>()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const state = computed(() => ctx.uploadState.value)
const cancelUpload = computed(() => ctx.cancelUpload.value)
const overallProgress = computed(() => {
const s = state.value
@@ -0,0 +1,212 @@
import type {
AbstractModrinthClient,
Kyros,
UploadProgress,
UploadState,
} from '@modrinth/api-client'
import type { Ref } from 'vue'
import type { CancelUploadHandler } from '#ui/providers/server-context'
export type UploadSessionUploadFile = {
file: File
filename: string
}
export type UploadSessionUploadResult = 'completed' | 'cancelled'
export function useUploadSessionUpload(options: {
client: AbstractModrinthClient
scope: Kyros.UploadSessions.v1.Scope
worldId: Ref<string | null>
uploadState: Ref<UploadState>
cancelUpload: Ref<CancelUploadHandler | null>
}) {
let activeUploadCancel: CancelUploadHandler | null = null
function getUploadByteCount(files: File[]) {
return files.reduce((sum, file) => sum + file.size, 0)
}
function resetUploadState() {
options.uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}
}
function startUploadState(files: File[]) {
options.uploadState.value = {
isUploading: true,
currentFileName: files[0]?.name ?? null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: getUploadByteCount(files),
completedFiles: 0,
totalFiles: files.length,
}
}
function setUploadProgressFromBytes(files: File[], uploadedBytes: number) {
const totalBytes = getUploadByteCount(files)
const boundedUploadedBytes = Math.max(0, Math.min(totalBytes, uploadedBytes))
let previousBytes = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
const nextBytes = previousBytes + file.size
if (boundedUploadedBytes >= nextBytes) {
previousBytes = nextBytes
continue
}
options.uploadState.value.currentFileName = file.name
options.uploadState.value.currentFileProgress =
file.size === 0 ? 1 : (boundedUploadedBytes - previousBytes) / file.size
options.uploadState.value.uploadedBytes = boundedUploadedBytes
options.uploadState.value.totalBytes = totalBytes
options.uploadState.value.completedFiles = i
return
}
options.uploadState.value.currentFileName =
files.length > 0 ? files[files.length - 1].name : null
options.uploadState.value.currentFileProgress = files.length > 0 ? 1 : 0
options.uploadState.value.uploadedBytes = totalBytes
options.uploadState.value.totalBytes = totalBytes
options.uploadState.value.completedFiles = files.length
}
function setUploadProgressFromXhr(files: File[], progress: UploadProgress) {
const totalBytes = getUploadByteCount(files)
const uploadedBytes =
progress.total > 0
? Math.round(totalBytes * progress.progress)
: Math.min(progress.loaded, totalBytes)
setUploadProgressFromBytes(files, uploadedBytes)
}
async function cancelUploadSession(worldId: string, uploadId: string) {
try {
await options.client.kyros.upload_sessions_v1.cancel(options.scope, worldId, uploadId)
} catch {
// ...
}
}
async function cancelUpload() {
await activeUploadCancel?.()
}
async function uploadFiles(files: UploadSessionUploadFile[]): Promise<UploadSessionUploadResult> {
if (files.length === 0) return 'cancelled'
if (options.uploadState.value.isUploading) return 'cancelled'
const worldId = options.worldId.value
if (!worldId) return 'cancelled'
const sourceFiles = files.map(({ file }) => file)
startUploadState(sourceFiles)
let cancelled = false
let finalized = false
let uploadId: string | null = null
let uploadHandle: { cancel: () => void } | null = null
let cancelRequest: Promise<void> | null = null
let cancelCompletion: Promise<void> | null = null
let resolveCancelCompletion: (() => void) | null = null
const waitForCancelCompletion = () => {
cancelCompletion ??= new Promise<void>((resolve) => {
resolveCancelCompletion = resolve
})
return cancelCompletion
}
const completeCancel = () => {
resolveCancelCompletion?.()
resolveCancelCompletion = null
cancelCompletion = null
}
const cancelSessionOnce = async () => {
if (!uploadId) return
cancelRequest ??= cancelUploadSession(worldId, uploadId)
await cancelRequest
}
const finishCancellation = async () => {
await cancelSessionOnce()
completeCancel()
}
const cancelCurrentUpload = async () => {
cancelled = true
uploadHandle?.cancel()
if (!uploadId) {
await waitForCancelCompletion()
return
}
await finishCancellation()
}
activeUploadCancel = cancelCurrentUpload
options.cancelUpload.value = cancelCurrentUpload
try {
const session = await options.client.kyros.upload_sessions_v1.create(options.scope, worldId)
uploadId = session.upload_id
if (cancelled) {
await finishCancellation()
return 'cancelled'
}
uploadHandle = options.client.kyros.upload_sessions_v1.uploadFiles(
options.scope,
worldId,
uploadId,
files,
{
onProgress: (progress) => setUploadProgressFromXhr(sourceFiles, progress),
},
)
await uploadHandle.promise
if (cancelled) {
await finishCancellation()
return 'cancelled'
}
setUploadProgressFromBytes(sourceFiles, getUploadByteCount(sourceFiles))
await options.client.kyros.upload_sessions_v1.finalize(options.scope, worldId, uploadId)
finalized = true
return 'completed'
} catch (error) {
if (uploadId && !finalized) {
await finishCancellation()
} else if (cancelled) {
completeCancel()
}
if (cancelled || (error instanceof Error && error.message === 'Upload cancelled')) {
return 'cancelled'
}
throw error
} finally {
if (activeUploadCancel === cancelCurrentUpload) {
activeUploadCancel = null
}
if (options.cancelUpload.value === cancelCurrentUpload) {
options.cancelUpload.value = null
}
if (cancelled) {
completeCancel()
}
resetUploadState()
}
}
return {
cancelUpload,
uploadFiles,
}
}
@@ -10,7 +10,7 @@ import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason } from '../providers/server-context'
import type { BusyReason, CancelUploadHandler } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
@@ -355,7 +355,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
completedFiles: 0,
totalFiles: 0,
})
const cancelUpload = ref<(() => void) | null>(null)
const cancelUpload = ref<CancelUploadHandler | null>(null)
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
const dismissedOpIds = ref<Set<string>>(new Set())
@@ -258,6 +258,7 @@ import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
@@ -268,8 +269,6 @@ import {
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { useBackupsSelection } from './backups-selection'
const messages = defineMessages({
selectAll: {
id: 'servers.backups.toolbar.select-all',
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -113,6 +114,13 @@ const messages = defineMessages({
const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
injectModrinthServerContext()
const contentUploadSession = useUploadSessionUpload({
client,
scope: 'content',
worldId,
uploadState,
cancelUpload,
})
const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const route = useRoute()
@@ -812,47 +820,17 @@ function handleUploadFiles() {
const wid = worldId.value
if (!wid) return
uploadState.value = {
isUploading: true,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
completedFiles: 0,
totalFiles: files.length,
}
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
onProgress: (p) => {
uploadState.value.currentFileProgress = p.progress
uploadState.value.uploadedBytes = p.loaded
uploadState.value.totalBytes = p.total
},
})
cancelUpload.value = () => handle.cancel()
try {
await handle.promise
uploadState.value.completedFiles = files.length
await contentQuery.refetch()
const result = await contentUploadSession.uploadFiles(
files.map((file) => ({ file, filename: file.name })),
)
if (result === 'completed') await contentQuery.refetch()
} catch (err) {
if (err instanceof Error && err.message === 'Upload cancelled') return
addNotification({
type: 'error',
title: formatMessage(messages.failedToUpload),
text: err instanceof Error ? err.message : undefined,
})
} finally {
cancelUpload.value = null
uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}
}
}
input.click()
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useReadyState } from '#ui/composables'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -25,7 +26,21 @@ const props = defineProps<{
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef } = serverContext
const {
serverId,
worldId,
fsOps,
busyReasons,
uploadState,
cancelUpload: cancelUploadRef,
} = serverContext
const fileUploadSession = useUploadSessionUpload({
client,
scope: 'files',
worldId,
uploadState,
cancelUpload: cancelUploadRef,
})
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -113,7 +128,13 @@ const {
staleTime: 30_000,
})
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
function isVisibleFileItem(item: Kyros.Files.v0.DirectoryItem) {
return !item.path.split('/').includes('.modrinth-staged')
}
const items = computed<FileItem[]>(() =>
(directoryData.value?.items ?? []).filter(isVisibleFileItem),
)
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
@@ -365,71 +386,33 @@ async function restartServer() {
await client.archon.servers_v0.power(serverId, 'Restart')
}
let activeUploadCancel: (() => void) | null = null
function getSessionUploadFilename(fileName: string) {
const basePath = currentPath.value.split('/').filter(Boolean).join('/')
return basePath ? `${basePath}/${fileName}` : fileName
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
uploadState.value = {
isUploading: true,
currentFileName: files[0].name,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes,
completedFiles: 0,
totalFiles: files.length,
}
cancelUploadRef.value = () => activeUploadCancel?.()
let completedBytes = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
const filePath = `${currentPath.value}/${file.name}`.replace('//', '/')
uploadState.value.currentFileName = file.name
uploadState.value.currentFileProgress = 0
try {
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
onProgress: ({ progress }) => {
uploadState.value.currentFileProgress = progress
uploadState.value.uploadedBytes = completedBytes + Math.round(file.size * progress)
},
})
activeUploadCancel = () => uploader.cancel()
await uploader.promise
completedBytes += file.size
uploadState.value.completedFiles = i + 1
uploadState.value.uploadedBytes = completedBytes
} catch (err) {
if (err instanceof Error && err.message === 'Upload cancelled') break
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: `Failed to upload ${file.name}`,
type: 'error',
})
}
}
activeUploadCancel = null
cancelUploadRef.value = null
refreshList()
uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
try {
const result = await fileUploadSession.uploadFiles(
files.map((file) => ({
file,
filename: getSessionUploadFilename(file.name),
})),
)
if (result === 'completed') refreshList()
} catch (err) {
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: err instanceof Error ? err.message : undefined,
type: 'error',
})
}
}
function cancelUpload() {
activeUploadCancel?.()
fileUploadSession.cancelUpload()
}
// Provide the file manager context
@@ -35,17 +35,15 @@
</template>
<script setup lang="ts">
// No ReadyTransition wrapper: console and ServerManageStats own their loading UX; there is no single TanStack "ready" gate for this tab.
import type { Mclogs } from '@modrinth/api-client'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
import { useModrinthServersConsole } from '#ui/composables'
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import ServerManageStats from './components/ServerManageStats.vue'
const props = withDefaults(
defineProps<{
showAdvancedDebugInfo?: boolean
+3 -1
View File
@@ -16,6 +16,8 @@ export interface FilesystemAuth {
token: string
}
export type CancelUploadHandler = () => void | Promise<void>
export interface ModrinthServerContext {
readonly serverId: string
readonly worldId: Ref<string | null>
@@ -44,7 +46,7 @@ export interface ModrinthServerContext {
// File upload state
readonly uploadState: Ref<UploadState>
readonly cancelUpload: Ref<(() => void) | null>
readonly cancelUpload: Ref<CancelUploadHandler | null>
// File operations (extract, move, etc.)
readonly activeOperations: ComputedRef<FileOperation[]>
@@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
import EditServerIcon from '../../components/servers/edit-server-icon/EditServerIcon.vue'
import { provideModrinthServerContext } from '../../providers'
import type { ModrinthServerContext } from '../../providers/server-context'
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
const meta = {
title: 'Servers/EditServerIcon',
@@ -73,7 +73,7 @@ const meta = {
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
refreshFsAuth: async () => {},
uploadState,
cancelUpload: ref(null),
cancelUpload: ref<CancelUploadHandler | null>(null),
activeOperations: computed(() => []),
dismissOperation: async () => {},
}
@@ -8,7 +8,7 @@ import ServerPanelAdmonitions from '../../components/servers/admonitions/ServerP
import { defineMessage } from '../../composables/i18n'
import type { FileOperation } from '../../layouts/shared/files-tab/types'
import { provideModrinthServerContext } from '../../providers'
import type { ModrinthServerContext } from '../../providers/server-context'
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
const meta = {
title: 'Servers/ServerPanelAdmonitions',
@@ -92,7 +92,8 @@ const meta = {
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
refreshFsAuth: async () => {},
uploadState,
cancelUpload: ref(() => {
cancelUpload: ref<CancelUploadHandler | null>(async () => {
await new Promise((resolve) => setTimeout(resolve, 1200))
uploadState.value = { ...uploadState.value, isUploading: false }
}),
activeOperations: computed(() => fileOp.value),