Add server unzipping (#3622)

* Initial unzipping feature

* Remove explicit backup provider naming from frontend

* CF placeholder

* Use regex for CF links

* Lint

* Add unzip warning for conflicting files, fix hydration error

* Adjust conflict modal ui

* Fix old queued ops sticking around, remove conflict warning

* Add vscode "editor.detectIndentation": true
This commit is contained in:
Prospector
2025-05-07 19:08:38 -07:00
committed by GitHub
parent 1884410e0d
commit 16766be82f
23 changed files with 1042 additions and 255 deletions

View File

@@ -303,7 +303,7 @@
</svg>
<h2 class="m-0 text-lg font-bold">Backups included</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Every server comes with 15 backups stored securely off-site with Backblaze.
Every server comes with 15 backups stored securely off-site.
</h3>
</div>
</div>

View File

@@ -787,6 +787,40 @@ const handleWebSocketMessage = (data: WSEvent) => {
break;
}
case "filesystem-ops": {
if (!server.fs) {
console.error("FilesystemOps received, but server.fs is not available", data.all);
break;
}
if (JSON.stringify(server.fs.ops) !== JSON.stringify(data.all)) {
server.fs.ops = data.all;
}
server.fs.queuedOps = server.fs.queuedOps.filter(
(queuedOp) => !data.all.some((x) => x.src === queuedOp.src),
);
const cancelled = data.all.filter((x) => x.state === "cancelled");
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, "dismiss")));
const completed = data.all.filter((x) => x.state === "done");
if (completed.length > 0) {
setTimeout(
async () =>
await Promise.all(
completed.map((x) => {
if (!server.fs?.opsQueuedForModification.includes(x.id)) {
server.fs?.opsQueuedForModification.push(x.id);
return server.fs?.modifyOp(x.id, "dismiss");
}
return Promise.resolve();
}),
),
3000,
);
}
break;
}
default:
console.warn("Unhandled WebSocket event:", data);
}

View File

@@ -56,8 +56,7 @@
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, securely off-site with
Backblaze.
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div

View File

@@ -5,6 +5,8 @@
:type="newItemType"
@create="handleCreateNewItem"
/>
<FilesUploadZipUrlModal ref="uploadZipModal" :server="server" />
<FilesUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
<LazyUiServersFilesRenameItemModal
ref="renameItemModal"
@@ -35,9 +37,12 @@
:breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery"
:current-filter="viewFilter"
:base-id="`browse-navbar-${baseId}`"
@navigate="navigateToSegment"
@create="showCreateModal"
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@filter="handleFilter"
@update:search-query="searchQuery = $event"
/>
@@ -46,6 +51,110 @@
:sort-desc="sortDesc"
@sort="handleSort"
/>
<div
v-for="op in ops"
:key="`fs-op-${op.op}-${op.src}`"
class="sticky top-20 z-20 grid grid-cols-[auto_1fr_auto] items-center gap-2 border-0 border-b-[1px] border-solid border-button-bg bg-table-alternateRow px-4 py-2 md:grid-cols-[auto_1fr_1fr_2fr_auto]"
>
<div>
<PackageOpenIcon class="h-5 w-5 text-secondary" />
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 md:contents">
<div class="flex items-center text-wrap break-all text-sm font-bold text-contrast">
Extracting {{ op.src.includes("https://") ? "modpack from URL" : op.src }}
</div>
<span
class="flex items-center gap-2 text-sm font-semibold"
:class="{
'text-green': op.state === 'done',
'text-red': op.state?.startsWith('fail'),
'text-orange': !op.state?.startsWith('fail') && op.state !== 'done',
}"
>
<template v-if="op.state === 'done'">
Done
<CheckIcon style="stroke-width: 3px" />
</template>
<template v-else-if="op.state?.startsWith('fail')">
Failed
<XIcon style="stroke-width: 3px" />
</template>
<template v-else-if="op.state === 'cancelled'">
<SpinnerIcon class="animate-spin" />
Cancelling
</template>
<template v-else-if="op.state === 'queued'">
<SpinnerIcon class="animate-spin" />
Queued...
</template>
<template v-else-if="op.state === 'ongoing'">
<SpinnerIcon class="animate-spin" />
Extracting...
</template>
<template v-else>
<UnknownIcon />
Unknown state: {{ op.state }}
</template>
</span>
<div class="col-span-2 flex grow flex-col gap-1 md:col-span-1 md:items-end">
<div class="text-xs font-semibold text-contrast opacity-80">
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
{{
"current_file" in op
? op.current_file?.split("/")?.pop() ?? "unknown"
: "unknown"
}}
</span>
</div>
<ProgressBar
:progress="'progress' in op ? op.progress : 0"
:max="1"
:color="
op.state === 'done'
? 'green'
: op.state?.startsWith('fail')
? 'red'
: op.state === 'cancelled'
? 'gray'
: 'orange'
"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
/>
<div
class="text-xs text-secondary opacity-80"
:class="{ invisible: 'bytes_processed' in op && !op.bytes_processed }"
>
{{ "bytes_processed" in op ? formatBytes(op.bytes_processed) : "0 B" }} extracted
</div>
</div>
</div>
<div>
<ButtonStyled circular>
<button
:disabled="!('id' in op) || !op.id"
class="radial-progress-animation-overlay"
:class="{ active: op.state === 'done' }"
@click="
() => {
op.state === 'done'
? server.fs?.modifyOp(op.id, 'dismiss')
: 'id' in op
? server.fs?.modifyOp(op.id, 'cancel')
: () => {};
}
"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<pre
v-if="flags.advancedDebugInfo"
class="markdown-body col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ op }}</pre
>
</div>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
@@ -55,7 +164,6 @@
@upload-complete="refreshList()"
/>
</div>
<UiServersFilesEditingNavbar
v-else
:file-name="editingFile?.name"
@@ -97,10 +205,10 @@
/>
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
</div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFileVirtualList
:items="filteredItems"
@extract="handleExtractItem"
@delete="showDeleteModal"
@rename="showRenameModal"
@download="downloadFile"
@@ -159,10 +267,32 @@
<script setup lang="ts">
import { useInfiniteScroll } from "@vueuse/core";
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
import {
UnknownIcon,
XIcon,
SpinnerIcon,
PackageOpenIcon,
CheckIcon,
UploadIcon,
FolderOpenIcon,
} from "@modrinth/assets";
import { computed } from "vue";
import { ButtonStyled, ProgressBar } from "@modrinth/ui";
import { formatBytes } from "@modrinth/utils";
import {
type DirectoryResponse,
type DirectoryItem,
type Server,
handleError,
} from "~/composables/pyroServers.ts";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
import type { FilesystemOp, FSQueuedOp } from "~/types/servers.ts";
import FilesUploadZipUrlModal from "~/components/ui/servers/FilesUploadZipUrlModal.vue";
import FilesUploadConflictModal from "~/components/ui/servers/FilesUploadConflictModal.vue";
const flags = useFeatureFlags();
const baseId = useId();
interface BaseOperation {
type: "move" | "rename";
@@ -217,6 +347,8 @@ const createItemModal = ref();
const renameItemModal = ref();
const moveItemModal = ref();
const deleteItemModal = ref();
const uploadZipModal = ref();
const uploadConflictModal = ref();
const newItemType = ref<"file" | "directory">("file");
const selectedItem = ref<any>(null);
@@ -449,6 +581,33 @@ const handleRenameItem = async (newName: string) => {
}
};
const extractItem = async (path: string) => {
try {
await props.server.fs?.extractFile(path, true, false);
} catch (error) {
console.error("Error extracting item:", error);
handleError(error);
}
};
const handleExtractItem = async (item: { name: string; type: string; path: string }) => {
try {
const dry = await props.server.fs?.extractFile(item.path, true, true, true);
if (dry) {
if (dry.conflicting_files.length === 0) {
await extractItem(item.path);
} else {
uploadConflictModal.value.show(item.path, dry.conflicting_files);
}
} else {
handleError(new Error("Error running dry run"));
}
} catch (error) {
console.error("Error extracting item:", error);
handleError(error);
}
};
const handleMoveItem = async (destination: string) => {
try {
const itemType = selectedItem.value.type;
@@ -536,6 +695,10 @@ const showCreateModal = (type: "file" | "directory") => {
createItemModal.value?.show();
};
const showUnzipFromUrlModal = (cf: boolean) => {
uploadZipModal.value?.show(cf);
};
const showRenameModal = (item: any) => {
selectedItem.value = item;
renameItemModal.value?.show(item);
@@ -760,6 +923,8 @@ onMounted(async () => {
redoLastOperation();
}
});
props.server.fs?.clearQueuedOps();
});
onUnmounted(() => {
@@ -768,6 +933,22 @@ onUnmounted(() => {
document.removeEventListener("keydown", () => {});
});
const clientSideQueued = computed<FSQueuedOp[]>(() => props.server.fs?.queuedOps ?? []);
type QueuedOpWithState = FSQueuedOp & { state: "queued" };
const ops = computed<(QueuedOpWithState | FilesystemOp)[]>(() => [
...clientSideQueued.value.map((x) => ({ ...x, state: "queued" }) satisfies QueuedOpWithState),
...(props.server.fs?.ops ?? []),
]);
watch(
() => props.server.fs?.ops,
() => {
refreshList();
},
);
watch(
() => route.query,
async (newQuery) => {
@@ -984,4 +1165,43 @@ const onScroll = () => {
transform: scale(1);
opacity: 1;
}
.radial-progress-animation-overlay {
position: relative;
}
@property --_radial-percentage {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.radial-progress-animation-overlay.active::before {
animation: radial-progress 3s linear forwards;
}
.radial-progress-animation-overlay::before {
content: "";
inset: -2px;
position: absolute;
border-radius: 50%;
box-sizing: content-box;
border: 2px solid var(--color-button-bg);
filter: brightness(var(--hover-brightness));
mask-image: conic-gradient(
black 0%,
black var(--_radial-percentage),
transparent var(--_radial-percentage),
transparent 100%
);
}
@keyframes radial-progress {
from {
--_radial-percentage: 0%;
}
to {
--_radial-percentage: 100%;
}
}
</style>