Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/api-client/src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ArchonTransfersInternalModule } from './archon/transfers/internal'
import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosFilesV1Module } from './kyros/files/v1'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
Expand Down Expand Up @@ -98,6 +99,7 @@ export const MODULE_REGISTRY = {
launchermeta_manifest_v0: LauncherMetaManifestV0Module,
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_files_v1: KyrosFilesV1Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
Expand Down
4 changes: 3 additions & 1 deletion packages/api-client/src/modules/kyros/files/v0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,18 +239,20 @@ export class KyrosFilesV0Module extends AbstractModule {
* @param path - Path to archive file
* @param override - If true, overwrite existing files
* @param dry - If true, perform dry run (returns conflicts without extracting)
* @param target - Directory to extract the archive into
* @returns Extract result with modpack name and conflicting files
*/
public async extractFile(
path: string,
override: boolean = true,
dry: boolean = false,
target: string = '/',
): Promise<Kyros.Files.v0.ExtractResult> {
return this.client.request<Kyros.Files.v0.ExtractResult>('/fs/unarchive', {
api: '',
version: 'v1',
method: 'POST',
params: { src: path, trg: '/', override, dry },
params: { src: path, trg: target, override, dry },
useNodeAuth: true,
})
}
Expand Down
87 changes: 87 additions & 0 deletions packages/api-client/src/modules/kyros/files/v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Kyros } from '../types'

export class KyrosFilesV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_files_v1'
}

/**
* Get metadata for a path in world storage.
*/
public async stat(
worldId: string,
data: Kyros.Files.v1.FileStatRequest,
): Promise<Kyros.Files.v1.FileStatResponse> {
return this.client.request<Kyros.Files.v1.FileStatResponse>(`/worlds/${worldId}/files/stat`, {
api: '',
version: 'v1',
method: 'POST',
body: data,
useNodeAuth: true,
})
}

/**
* Create a ZIP archive beside a directory in world storage.
*/
public async createZip(
worldId: string,
data: Kyros.Files.v1.ZipRequest,
onProgress?: (record: Kyros.Files.v1.ZipProgress) => void,
): Promise<void> {
const stream = await this.client.stream(`/worlds/${worldId}/files/zip`, {
api: '',
version: 'v1',
method: 'POST',
body: data,
useNodeAuth: true,
headers: { Accept: 'application/json-seq' },
})
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let completed = false

const parseRecord = (value: string) => {
const trimmedValue = value.trim()
const text = trimmedValue.startsWith('\u001e') ? trimmedValue.slice(1).trim() : trimmedValue
if (!text) return
const record = JSON.parse(text) as Kyros.Files.v1.ZipProgress
onProgress?.(record)
if (record.error) throw new Error(record.error)
if (record.done === true) completed = true
}

const parseRecords = (flush = false) => {
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
parseRecord(buffer.slice(0, newlineIndex))
buffer = buffer.slice(newlineIndex + 1)
newlineIndex = buffer.indexOf('\n')
}
if (flush && buffer.trim()) {
parseRecord(buffer)
buffer = ''
}
}

try {
while (!completed) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
parseRecords()
}
if (completed) {
await reader.cancel().catch(() => undefined)
return
}
buffer += decoder.decode()
parseRecords(true)
if (!completed) throw new Error('ZIP operation ended before completion')
} finally {
reader.releaseLock()
}
}
}
35 changes: 35 additions & 0 deletions packages/api-client/src/modules/kyros/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,41 @@ export namespace Kyros {
}

export namespace Files {
export namespace v1 {
export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other'

export interface FileStatRequest {
path: string
}

export interface FileStatResponse {
name: string
full_path: string
size_bytes: number
type: DescendantType
mtime: string
ctime: string
}

export type ZipRequest =
| {
target_type: 'Directory'
path: string
}
| {
target_type: 'ManyPaths'
parent: string
include: string[]
target: string
}

export interface ZipProgress {
progress: number
done?: boolean
error?: string
}
}

export namespace v0 {
export interface DirectoryItem {
name: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@
:dismissible="dismissible && isTerminal"
:progress="'progress' in op ? (op.progress ?? 0) : 0"
:progress-color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
:waiting="!isTerminal && (op.state === 'queued' || !op.progress || op.progress === 0)"
@dismiss="$emit('dismiss')"
>
<template #icon="{ iconClass }">
<PackageOpenIcon :class="iconClass" />
<FolderArchiveIcon v-if="op.op === 'zip'" :class="iconClass" />
<PackageOpenIcon v-else :class="iconClass" />
</template>
<template #header>{{ title }}</template>
<span class="text-secondary">
<span>
<span v-if="op.state?.startsWith('fail') && op.error">
{{ op.error }}
</span>
<span v-else-if="op.op === 'zip'">
{{ formatMessage(messages.compressed, { progress: Math.round((op.progress ?? 0) * 100) }) }}
</span>
<span v-else>
{{
formatMessage(messages.extracted, {
size: formatBytes(op.bytes_processed ?? 0),
Expand All @@ -25,7 +32,7 @@
</span>
<template v-if="op.id" #top-right-actions>
<Button
v-if="!isTerminal"
v-if="!isTerminal && op.cancellable !== false"
v-tooltip="!canWriteFiles ? permissionDeniedMessage : undefined"
type="outlined"
class="!border !text-blue [&>svg]:!text-blue !shadow-[inset_0_0_0_1px_var(--color-blue)]"
Expand All @@ -40,7 +47,7 @@
</template>

<script setup lang="ts">
import { PackageOpenIcon } from '@modrinth/assets'
import { FolderArchiveIcon, PackageOpenIcon } from '@modrinth/assets'
import { computed } from 'vue'

import Admonition from '#ui/components/base/Admonition.vue'
Expand Down Expand Up @@ -89,6 +96,22 @@ const messages = defineMessages({
id: 'files.operations.current-file',
defaultMessage: 'Current file: {file}',
},
compressing: {
id: 'files.operations.compressing',
defaultMessage: 'Creating {source}',
},
compressingCompleted: {
id: 'files.operations.compressing-completed',
defaultMessage: 'Creating {source} finished',
},
compressingFailed: {
id: 'files.operations.compressing-failed',
defaultMessage: 'Creating {source} failed',
},
compressed: {
id: 'files.operations.compressed',
defaultMessage: '{progress}% compressed',
},
})

const isTerminal = computed(() => props.op.state === 'done' || !!props.op.state?.startsWith('fail'))
Expand All @@ -97,6 +120,15 @@ const sourceName = computed(() =>
)

const title = computed(() => {
if (props.op.op === 'zip') {
if (props.op.state === 'done') {
return formatMessage(messages.compressingCompleted, { source: sourceName.value })
}
if (props.op.state?.startsWith('fail')) {
return formatMessage(messages.compressingFailed, { source: sourceName.value })
}
return formatMessage(messages.compressing, { source: sourceName.value })
}
if (props.op.state === 'done') {
return formatMessage(messages.extractingCompleted, { source: sourceName.value })
}
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/composables/server-manage-core-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,21 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp

type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
const dismissedOpIds = ref<Set<string>>(new Set())
const localFileOperations = ref<FileOperation[]>([])

const activeOperations = computed<FileOperation[]>(() => [
...localFileOperations.value,
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
...(fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)) as FileOperation[]),
])

async function dismissOperation(opId: string, action: 'dismiss' | 'cancel') {
if (localFileOperations.value.some((operation) => operation.id === opId)) {
localFileOperations.value = localFileOperations.value.filter(
(operation) => operation.id !== opId,
)
return
}
if (action === 'dismiss') {
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
}
Expand All @@ -400,6 +408,17 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
}

function upsertLocalFileOperation(operation: FileOperation) {
const index = localFileOperations.value.findIndex((item) => item.id === operation.id)
if (index === -1) {
localFileOperations.value = [...localFileOperations.value, operation]
return
}
localFileOperations.value = localFileOperations.value.map((item, itemIndex) =>
itemIndex === index ? operation : item,
)
}

const refreshFsAuth = async () => {
if (!options.serverId.value) {
fsAuth.value = null
Expand Down Expand Up @@ -439,6 +458,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
cancelUpload,
activeOperations,
dismissOperation,
upsertLocalFileOperation,
})

setNodeAuthState(() => fsAuth.value, refreshFsAuth)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
FolderArchiveIcon,
FolderCogIcon,
FolderOpenIcon,
GlassesIcon,
Expand Down Expand Up @@ -119,6 +120,10 @@ const messages = defineMessages({
id: 'files.row.item-count',
defaultMessage: '{count, plural, one {# item} other {# items}}',
},
createZip: {
id: 'files.row.create-zip',
defaultMessage: 'Create ZIP',
},
})

const props = defineProps<
Expand All @@ -133,7 +138,16 @@ const props = defineProps<

const emit = defineEmits<{
(
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover' | 'navigate',
e:
| 'rename'
| 'move'
| 'download'
| 'zip'
| 'delete'
| 'edit'
| 'extract'
| 'hover'
| 'navigate',
item: Pick<FileItem, 'name' | 'type' | 'path'>,
): void
(
Expand Down Expand Up @@ -227,6 +241,16 @@ const menuOptions = computed<ButtonMenuOption[]>(() => {
action: () => emit('extract', item),
},
{ type: 'divider', shown: canExtract.value },
{
id: 'zip',
label: formatMessage(messages.createZip),
icon: FolderArchiveIcon,
shown: props.type === 'directory' && !!ctx.zipFolder,
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('zip', item),
},
{ type: 'divider', shown: props.type === 'directory' && !!ctx.zipFolder },
{
id: 'rename',
label: formatMessage(commonMessages.renameButton),
Expand Down
Loading
Loading