You've already forked AstralRinth
forked from didirus/AstralRinth
Files UX Sprint (#3019)
* chore: dedupe lockfile Signed-off-by: Evan Song <theevansong@gmail.com> * fix: incorrect spacing between editing and browsing state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve files image viewer toolbar Signed-off-by: Evan Song <theevansong@gmail.com> * chore: image viewer cursor affordance Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean imports Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add tooltips Signed-off-by: Evan Song <theevansong@gmail.com> * chore: use black background Signed-off-by: Evan Song <theevansong@gmail.com> * feat: show scale factor, handle large images, consolidate state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add types to fs operations Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add date create sorting option Signed-off-by: Evan Song <theevansong@gmail.com> * fix: match name of folder creation modal Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add it here too Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add creation date to file item, file manager header Signed-off-by: Evan Song <theevansong@gmail.com> * chore: a11y Signed-off-by: Evan Song <theevansong@gmail.com> * fix: ensure move item modal always has leading slash Signed-off-by: Evan Song <theevansong@gmail.com> * fix: correct move input placeholder Signed-off-by: Evan Song <theevansong@gmail.com> * chore: correct design disparity Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add better affordance on active file item state Signed-off-by: Evan Song <theevansong@gmail.com> * fix: correct instances where we dont sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: notify that server restarted on saveandrestart Signed-off-by: Evan Song <theevansong@gmail.com> * fix: consolidate error state in file manager Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust sizing Signed-off-by: Evan Song <theevansong@gmail.com> * feat: drag and drop file items to move them Signed-off-by: Evan Song <theevansong@gmail.com> * feat: enable ability to drag folders too Signed-off-by: Evan Song <theevansong@gmail.com> * feat: better file movement toasts Signed-off-by: Evan Song <theevansong@gmail.com> * just say u hate me Signed-off-by: Evan Song <theevansong@gmail.com> * feat: uploading indicator for file uploads Signed-off-by: Evan Song <theevansong@gmail.com> * chore: cleaner file item ghost when dragging Signed-off-by: Evan Song <theevansong@gmail.com> * fix: enforce max length and truncate on ghost Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve item rename toast Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve item create toast Signed-off-by: Evan Song <theevansong@gmail.com> * feat: undo and redo stack Signed-off-by: Evan Song <theevansong@gmail.com> * fix: confusing behavior where folders were not sorted alphabetically Signed-off-by: Evan Song <theevansong@gmail.com> * feat: find and replace in file editor Signed-off-by: Evan Song <theevansong@gmail.com> * feat: correctly set language mode of file editor Signed-off-by: Evan Song <theevansong@gmail.com> * chore: slop Signed-off-by: Evan Song <theevansong@gmail.com> * chore: actually handle case with multiple dots in file name before setting language Signed-off-by: Evan Song <theevansong@gmail.com> * fix: match move icons in file context/threedot Signed-off-by: Evan Song <theevansong@gmail.com> * feat: upload indicator Signed-off-by: Evan Song <theevansong@gmail.com> * chore: dedupe lockfile again Signed-off-by: Evan Song <theevansong@gmail.com> * lockfile Signed-off-by: Evan Song <theevansong@gmail.com> * fix: file undefinedness Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * remove shitty animation logic Signed-off-by: Evan Song <theevansong@gmail.com> * feat: file upload queuer Signed-off-by: Evan Song <theevansong@gmail.com> * chore: only allow editable files to have active affordance Signed-off-by: Evan Song <theevansong@gmail.com> * fix: properly throw pyrofetcherror when rename fails Signed-off-by: Evan Song <theevansong@gmail.com> * feat: cancel file uploads Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> --------- Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
@@ -596,17 +596,22 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
errorMessage.value = data.reason ?? "Unknown error";
|
||||
error.value = new Error(data.reason ?? "Unknown error");
|
||||
let files = await server.fs?.listDirContents("/", 1, 100);
|
||||
if (files.total > 1) {
|
||||
for (let i = 1; i < files.total; i++) {
|
||||
files = await server.fs?.listDirContents("/", i, 100);
|
||||
if (files.items?.length === 0) break;
|
||||
if (files) {
|
||||
if (files.total > 1) {
|
||||
for (let i = 1; i < files.total; i++) {
|
||||
const nextFiles = await server.fs?.listDirContents("/", i, 100);
|
||||
if (nextFiles?.items?.length === 0) break;
|
||||
if (nextFiles) files = nextFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fileName = await files.items?.find((file: { name: string }) =>
|
||||
const fileName = files?.items?.find((file: { name: string }) =>
|
||||
file.name.startsWith("modrinth-installation"),
|
||||
)?.name;
|
||||
errorLogFile.value = fileName;
|
||||
errorLog.value = await server.fs?.downloadFile(fileName);
|
||||
errorLogFile.value = fileName ?? "";
|
||||
if (fileName) {
|
||||
errorLog.value = await server.fs?.downloadFile(fileName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="`Search ${type}s...`"
|
||||
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
@@ -93,7 +93,7 @@
|
||||
</span>
|
||||
<FilterIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #all> All {{ type }}s </template>
|
||||
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
|
||||
<template #enabled> Only enabled </template>
|
||||
<template #disabled> Only disabled </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
@@ -238,8 +238,10 @@
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<PackageClosedIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">No {{ type }}s found!</p>
|
||||
<p class="m-0">Add some {{ type }}s to your server to manage them here.</p>
|
||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||
<p class="m-0">
|
||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||
</p>
|
||||
<ButtonStyled color="brand">
|
||||
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
||||
<PlusIcon />
|
||||
@@ -333,7 +335,7 @@ const filterMethodLabel = computed(() => {
|
||||
case "enabled":
|
||||
return "Only enabled";
|
||||
default:
|
||||
return `All ${type.value}s`;
|
||||
return `All ${type.value.toLocaleLowerCase()}s`;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -33,17 +33,106 @@
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
||||
<UiServersFilesBrowseNavbar
|
||||
v-if="!isEditing"
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:sort-method="sortMethod"
|
||||
@navigate="navigateToSegment"
|
||||
@sort="sortFiles"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<div v-if="!isEditing" class="contents">
|
||||
<UiServersFilesBrowseNavbar
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:sort-method="sortMethod"
|
||||
@navigate="navigateToSegment"
|
||||
@sort="sortFiles"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<Transition
|
||||
name="upload-status"
|
||||
@enter="onUploadStatusEnter"
|
||||
@leave="onUploadStatusLeave"
|
||||
>
|
||||
<div
|
||||
v-if="isUploading"
|
||||
ref="uploadStatusRef"
|
||||
class="upload-status rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow text-contrast"
|
||||
>
|
||||
<div class="flex flex-col p-4 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
File Uploads{{
|
||||
activeUploads.length > 0 ? ` - ${activeUploads.length} left` : ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="item.status === 'error' || item.status === 'cancelled'"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<UiServersFilesEditingNavbar
|
||||
v-else
|
||||
@@ -64,7 +153,20 @@
|
||||
:is="VAceEditor"
|
||||
v-if="!isEditingImage"
|
||||
v-model:value="fileContent"
|
||||
lang="json"
|
||||
:lang="
|
||||
(() => {
|
||||
const ext = editingFile?.name?.split('.')?.pop()?.toLowerCase() ?? '';
|
||||
return ext === 'json'
|
||||
? 'json'
|
||||
: ext === 'toml'
|
||||
? 'toml'
|
||||
: ext === 'sh'
|
||||
? 'sh'
|
||||
: ['yml', 'yaml'].includes(ext)
|
||||
? 'yaml'
|
||||
: 'text';
|
||||
})()
|
||||
"
|
||||
theme="one_dark"
|
||||
:print-margin="false"
|
||||
style="height: 750px; font-size: 1rem"
|
||||
@@ -73,13 +175,16 @@
|
||||
/>
|
||||
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||
<UiServersFilesLabelBar />
|
||||
<UiServersFileVirtualList
|
||||
:items="filteredItems"
|
||||
@delete="showDeleteModal"
|
||||
@rename="showRenameModal"
|
||||
@download="downloadFile"
|
||||
@move="showMoveModal"
|
||||
@move-direct-to="handleDirectMove"
|
||||
@edit="editFile"
|
||||
@contextmenu="showContextMenu"
|
||||
@load-more="handleLoadMore"
|
||||
@@ -87,35 +192,28 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isLoading && items.length === 0"
|
||||
v-else-if="!isLoading && items.length === 0 && !loadError"
|
||||
class="flex h-full w-full items-center justify-center p-20"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||
<h3 class="text-2xl font-bold text-contrast">This folder is empty</h3>
|
||||
<h3 class="m-0 text-2xl font-bold text-contrast">This folder is empty</h3>
|
||||
<p class="m-0 text-sm text-secondary">There are no files or folders.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LazyUiServersFileManagerError
|
||||
v-else-if="!isLoading"
|
||||
title="Unable to list files"
|
||||
message="Unfortunately, we were unable to list the files in this folder. If this issue persists, contact support."
|
||||
@refetch="refreshList"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
|
||||
<LazyUiServersFileManagerError
|
||||
v-else-if="loadError"
|
||||
title="Unable to fetch files"
|
||||
message="This path is invalid or the server is not responding."
|
||||
title="Unable to load files"
|
||||
message="The folder may not exist."
|
||||
@refetch="refreshList"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-xl bg-black bg-opacity-50 text-white"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16" />
|
||||
@@ -140,8 +238,38 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteScroll } from "@vueuse/core";
|
||||
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
||||
|
||||
interface BaseOperation {
|
||||
type: "move" | "rename";
|
||||
itemType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface MoveOperation extends BaseOperation {
|
||||
type: "move";
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
}
|
||||
|
||||
interface RenameOperation extends BaseOperation {
|
||||
type: "rename";
|
||||
path: string;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
type Operation = MoveOperation | RenameOperation;
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
@@ -154,6 +282,8 @@ const VAceEditor = ref();
|
||||
const mainContent = ref<HTMLElement | null>(null);
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const contextMenu = ref();
|
||||
const operationHistory = ref<Operation[]>([]);
|
||||
const redoStack = ref<Operation[]>([]);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const sortMethod = ref("default");
|
||||
@@ -184,13 +314,52 @@ const imagePreview = ref();
|
||||
const isDragging = ref(false);
|
||||
const dragCounter = ref(0);
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||
const uploadQueue = ref<UploadItem[]>([]);
|
||||
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||
);
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
};
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return;
|
||||
const el = uploadStatusRef.value;
|
||||
const itemsHeight = uploadQueue.value.length * 32;
|
||||
const headerHeight = 12;
|
||||
const gap = 8;
|
||||
const padding = 32;
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight;
|
||||
el.style.height = `${totalHeight}px`;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
useHead({
|
||||
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
||||
});
|
||||
|
||||
const fetchDirectoryContents = async (): Promise<{ items: any[]; total: number }> => {
|
||||
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
||||
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
||||
try {
|
||||
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
||||
@@ -240,16 +409,97 @@ const refreshList = () => {
|
||||
reset();
|
||||
};
|
||||
|
||||
const undoLastOperation = async () => {
|
||||
const lastOperation = operationHistory.value.pop();
|
||||
if (!lastOperation) return;
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case "move":
|
||||
await props.server.fs?.moveFileOrFolder(
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
);
|
||||
break;
|
||||
case "rename":
|
||||
await props.server.fs?.renameFileOrFolder(
|
||||
`${lastOperation.path}/${lastOperation.newName}`.replace("//", "/"),
|
||||
lastOperation.oldName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
redoStack.value.push(lastOperation);
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: `${lastOperation.type === "move" ? "Move" : "Rename"} undone`,
|
||||
text: `${lastOperation.fileName} has been restored to its original ${lastOperation.type === "move" ? "location" : "name"}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error undoing ${lastOperation.type}:`, error);
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Undo failed",
|
||||
text: `Failed to undo the last ${lastOperation.type} operation`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const redoLastOperation = async () => {
|
||||
const lastOperation = redoStack.value.pop();
|
||||
if (!lastOperation) return;
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case "move":
|
||||
await props.server.fs?.moveFileOrFolder(
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
);
|
||||
break;
|
||||
case "rename":
|
||||
await props.server.fs?.renameFileOrFolder(
|
||||
`${lastOperation.path}/${lastOperation.oldName}`.replace("//", "/"),
|
||||
lastOperation.newName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
operationHistory.value.push(lastOperation);
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: `${lastOperation.type === "move" ? "Move" : "Rename"} redone`,
|
||||
text: `${lastOperation.fileName} has been ${lastOperation.type === "move" ? "moved" : "renamed"} again`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error redoing ${lastOperation.type}:`, error);
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Redo failed",
|
||||
text: `Failed to redo the last ${lastOperation.type} operation`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewItem = async (name: string) => {
|
||||
try {
|
||||
const path = `${currentPath.value}/${name}`.replace("//", "/");
|
||||
await props.server.fs?.createFileOrFolder(path, newItemType.value);
|
||||
|
||||
refreshList();
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File created",
|
||||
text: "Your file has been created.",
|
||||
title: `${newItemType.value === "directory" ? "Folder" : "File"} created`,
|
||||
text: `New ${newItemType.value === "directory" ? "folder" : "file"} ${name} has been created.`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -262,6 +512,16 @@ const handleRenameItem = async (newName: string) => {
|
||||
const path = `${currentPath.value}/${selectedItem.value.name}`.replace("//", "/");
|
||||
await props.server.fs?.renameFileOrFolder(path, newName);
|
||||
|
||||
redoStack.value = [];
|
||||
operationHistory.value.push({
|
||||
type: "rename",
|
||||
itemType: selectedItem.value.type,
|
||||
fileName: selectedItem.value.name,
|
||||
path: currentPath.value,
|
||||
oldName: selectedItem.value.name,
|
||||
newName,
|
||||
});
|
||||
|
||||
refreshList();
|
||||
|
||||
if (closeEditor.value) {
|
||||
@@ -274,27 +534,90 @@ const handleRenameItem = async (newName: string) => {
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File renamed",
|
||||
text: "Your file has been renamed.",
|
||||
title: `${selectedItem.value.type === "directory" ? "Folder" : "File"} renamed`,
|
||||
text: `${selectedItem.value.name} has been renamed to ${newName}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
handleRenameError(error);
|
||||
console.error("Error renaming item:", error);
|
||||
if (error instanceof PyroFetchError) {
|
||||
if (error.statusCode === 400) {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename",
|
||||
text: `An item named "${newName}" already exists in this location`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename item",
|
||||
text: "An unexpected error occurred",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveItem = async (destination: string) => {
|
||||
try {
|
||||
const itemType = selectedItem.value.type;
|
||||
const sourcePath = currentPath.value;
|
||||
const newPath = `${destination}/${selectedItem.value.name}`.replace("//", "/");
|
||||
|
||||
await props.server.fs?.moveFileOrFolder(
|
||||
`${currentPath.value}/${selectedItem.value.name}`.replace("//", "/"),
|
||||
`${destination}/${selectedItem.value.name}`.replace("//", "/"),
|
||||
`${sourcePath}/${selectedItem.value.name}`.replace("//", "/"),
|
||||
newPath,
|
||||
);
|
||||
|
||||
redoStack.value = [];
|
||||
operationHistory.value.push({
|
||||
type: "move",
|
||||
sourcePath,
|
||||
destinationPath: destination,
|
||||
fileName: selectedItem.value.name,
|
||||
itemType,
|
||||
});
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File moved",
|
||||
text: "Your file has been moved.",
|
||||
title: `${itemType === "directory" ? "Folder" : "File"} moved`,
|
||||
text: `${selectedItem.value.name} has been moved to ${newPath}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error moving item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectMove = async (moveData: {
|
||||
name: string;
|
||||
type: string;
|
||||
path: string;
|
||||
destination: string;
|
||||
}) => {
|
||||
try {
|
||||
const newPath = `${moveData.destination}/${moveData.name}`.replace("//", "/");
|
||||
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf("/"));
|
||||
|
||||
await props.server.fs?.moveFileOrFolder(moveData.path, newPath);
|
||||
|
||||
redoStack.value = [];
|
||||
operationHistory.value.push({
|
||||
type: "move",
|
||||
sourcePath,
|
||||
destinationPath: moveData.destination,
|
||||
fileName: moveData.name,
|
||||
itemType: moveData.type,
|
||||
});
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: `${moveData.type === "directory" ? "Folder" : "File"} moved`,
|
||||
text: `${moveData.name} has been moved to ${newPath}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -356,40 +679,18 @@ const handleCreateError = (error: any) => {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Error creating item",
|
||||
text: "File already exists",
|
||||
text: "Something went wrong. The file may already exist.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameError = (error: any) => {
|
||||
console.error("Error renaming item:", error);
|
||||
if (error instanceof PyroFetchError) {
|
||||
if (error.statusCode === 400) {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename item",
|
||||
text: "This item already exists or is invalid.",
|
||||
type: "error",
|
||||
});
|
||||
} else if (error.statusCode === 500) {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename item",
|
||||
text: "Invalid file",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyDefaultSort = (items: any[]) => {
|
||||
const applyDefaultSort = (items: DirectoryItem[]) => {
|
||||
return items.sort((a: any, b: any) => {
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
if (a.count > b.count) return -1;
|
||||
if (a.count < b.count) return 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
@@ -406,6 +707,9 @@ const filteredItems = computed(() => {
|
||||
case "modified":
|
||||
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||
break;
|
||||
case "created":
|
||||
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||
break;
|
||||
case "filesOnly":
|
||||
result = result.filter((item) => item.type !== "directory");
|
||||
break;
|
||||
@@ -509,15 +813,31 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
|
||||
onMounted(async () => {
|
||||
await import("ace-builds");
|
||||
await import("ace-builds/src-noconflict/mode-json");
|
||||
await import("ace-builds/src-noconflict/mode-yaml");
|
||||
await import("ace-builds/src-noconflict/mode-toml");
|
||||
await import("ace-builds/src-noconflict/mode-sh");
|
||||
await import("ace-builds/src-noconflict/theme-one_dark");
|
||||
await import("ace-builds/src-noconflict/ext-searchbox");
|
||||
VAceEditor.value = markRaw((await import("vue3-ace-editor")).VAceEditor);
|
||||
document.addEventListener("click", onAnywhereClicked);
|
||||
window.addEventListener("scroll", onScroll);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === "z") {
|
||||
e.preventDefault();
|
||||
undoLastOperation();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "z") {
|
||||
e.preventDefault();
|
||||
redoLastOperation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", onAnywhereClicked);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
document.removeEventListener("keydown", () => {});
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -600,8 +920,10 @@ const requestShareLink = async () => {
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
@@ -618,43 +940,123 @@ const handleDragLeave = (event: DragEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
dragCounter.value = 0;
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||
if (isInternalMove) return;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadFile(files[i]);
|
||||
}
|
||||
Array.from(files).forEach((file) => {
|
||||
uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === "uploading") {
|
||||
item.uploader.cancel();
|
||||
item.status = "cancelled";
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
try {
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
||||
await props.server.fs?.uploadFile(filePath, file);
|
||||
refreshList();
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
size: formatFileSize(file.size),
|
||||
};
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File uploaded",
|
||||
text: "Your file has been uploaded.",
|
||||
type: "success",
|
||||
});
|
||||
uploadQueue.value.push(uploadItem);
|
||||
|
||||
try {
|
||||
uploadItem.status = "uploading";
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
||||
const uploader = await props.server.fs?.uploadFile(filePath, file);
|
||||
uploadItem.uploader = uploader;
|
||||
|
||||
if (uploader?.onProgress) {
|
||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await uploader?.promise;
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "completed";
|
||||
uploadQueue.value[index].progress = 100;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
await refreshList();
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "error";
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Upload failed",
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initiateFileUpload = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
input.multiple = true;
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@@ -708,6 +1110,13 @@ const saveFileContent = async (exit: boolean = true) => {
|
||||
const saveFileContentRestart = async () => {
|
||||
await saveFileContent();
|
||||
await props.server.general?.power("Restart");
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Server restarted",
|
||||
text: "Your server has been restarted.",
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const saveFileContentAs = async () => {
|
||||
@@ -734,3 +1143,38 @@ const onScroll = () => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-status {
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-status-enter-active,
|
||||
.upload-status-leave-active {
|
||||
transition: height 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-status-enter-from,
|
||||
.upload-status-leave-to {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.status-icon-enter-active,
|
||||
.status-icon-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.status-icon-enter-from,
|
||||
.status-icon-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-icon-enter-to,
|
||||
.status-icon-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user