You've already forked AstralRinth
forked from didirus/AstralRinth
16a6f7b352
* Modrinth Hosting rebranding * fix capitalization issue * fix issues
1183 lines
32 KiB
Vue
1183 lines
32 KiB
Vue
<template>
|
|
<div data-pyro-file-manager-root class="contents">
|
|
<FilesCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
|
<FilesUploadZipUrlModal ref="uploadZipModal" :server="server" />
|
|
<FilesUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
|
|
|
|
<FilesRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
|
|
|
<FilesMoveItemModal
|
|
ref="moveItemModal"
|
|
:item="selectedItem"
|
|
:current-path="currentPath"
|
|
@move="handleMoveItem"
|
|
/>
|
|
|
|
<FilesDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
|
|
|
|
<FilesUploadDragAndDrop
|
|
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
|
|
@files-dropped="handleDroppedFiles"
|
|
>
|
|
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
|
<div v-if="!isEditing" class="contents">
|
|
<FilesBrowseNavbar
|
|
: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"
|
|
/>
|
|
<FilesLabelBar :sort-field="sortMethod" :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"
|
|
class="rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow"
|
|
:current-path="currentPath"
|
|
:fs="props.server.fs"
|
|
@upload-complete="refreshList()"
|
|
/>
|
|
</div>
|
|
<FilesEditingNavbar
|
|
v-else
|
|
:file-name="editingFile?.name"
|
|
:is-image="isEditingImage"
|
|
:file-path="editingFile?.path"
|
|
:breadcrumb-segments="breadcrumbSegments"
|
|
@cancel="cancelEditing"
|
|
@save="() => saveFileContent(true)"
|
|
@save-as="saveFileContentAs"
|
|
@save-restart="saveFileContentRestart"
|
|
@share="requestShareLink"
|
|
@navigate="navigateToSegment"
|
|
/>
|
|
|
|
<div v-if="isEditing" class="h-full w-full flex-grow">
|
|
<component
|
|
:is="VAceEditor"
|
|
v-if="!isEditingImage"
|
|
v-model:value="fileContent"
|
|
: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"
|
|
class="ace_editor ace_hidpi ace-one-dark ace_dark rounded-b-lg"
|
|
@init="onInit"
|
|
/>
|
|
<FilesImageViewer v-else :image-blob="imagePreview" />
|
|
</div>
|
|
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
|
<FileVirtualList
|
|
:items="filteredItems"
|
|
@extract="handleExtractItem"
|
|
@delete="showDeleteModal"
|
|
@rename="showRenameModal"
|
|
@download="downloadFile"
|
|
@move="showMoveModal"
|
|
@move-direct-to="handleDirectMove"
|
|
@edit="editFile"
|
|
@contextmenu="showContextMenu"
|
|
@load-more="handleLoadMore"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
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="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>
|
|
|
|
<FileManagerError
|
|
v-else-if="loadError"
|
|
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-2xl bg-black bg-opacity-50 text-white"
|
|
>
|
|
<div class="text-center">
|
|
<UploadIcon class="mx-auto h-16 w-16" />
|
|
<p class="mt-2 text-xl">Drop files here to upload</p>
|
|
</div>
|
|
</div>
|
|
</FilesUploadDragAndDrop>
|
|
|
|
<FilesContextMenu
|
|
ref="contextMenu"
|
|
:item="contextMenuInfo.item"
|
|
:x="contextMenuInfo.x"
|
|
:y="contextMenuInfo.y"
|
|
:is-at-bottom="isAtBottom"
|
|
@rename="showRenameModal"
|
|
@move="showMoveModal"
|
|
@download="downloadFile"
|
|
@delete="showDeleteModal"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
CheckIcon,
|
|
FolderOpenIcon,
|
|
PackageOpenIcon,
|
|
SpinnerIcon,
|
|
UnknownIcon,
|
|
UploadIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import { ButtonStyled, injectNotificationManager, ProgressBar } from '@modrinth/ui'
|
|
import type { DirectoryItem, DirectoryResponse, FilesystemOp, FSQueuedOp } from '@modrinth/utils'
|
|
import { formatBytes, ModrinthServersFetchError } from '@modrinth/utils'
|
|
import { useInfiniteScroll } from '@vueuse/core'
|
|
import { computed } from 'vue'
|
|
|
|
import FileManagerError from '~/components/ui/servers/FileManagerError.vue'
|
|
import FilesBrowseNavbar from '~/components/ui/servers/FilesBrowseNavbar.vue'
|
|
import FilesContextMenu from '~/components/ui/servers/FilesContextMenu.vue'
|
|
import FilesCreateItemModal from '~/components/ui/servers/FilesCreateItemModal.vue'
|
|
import FilesDeleteItemModal from '~/components/ui/servers/FilesDeleteItemModal.vue'
|
|
import FilesEditingNavbar from '~/components/ui/servers/FilesEditingNavbar.vue'
|
|
import FilesImageViewer from '~/components/ui/servers/FilesImageViewer.vue'
|
|
import FilesLabelBar from '~/components/ui/servers/FilesLabelBar.vue'
|
|
import FilesMoveItemModal from '~/components/ui/servers/FilesMoveItemModal.vue'
|
|
import FilesRenameItemModal from '~/components/ui/servers/FilesRenameItemModal.vue'
|
|
import FilesUploadConflictModal from '~/components/ui/servers/FilesUploadConflictModal.vue'
|
|
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
|
|
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
|
|
import FilesUploadZipUrlModal from '~/components/ui/servers/FilesUploadZipUrlModal.vue'
|
|
import FileVirtualList from '~/components/ui/servers/FileVirtualList.vue'
|
|
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
|
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
|
|
|
|
const notifications = injectNotificationManager()
|
|
const { addNotification } = notifications
|
|
const flags = useFeatureFlags()
|
|
const baseId = useId()
|
|
|
|
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
|
|
|
|
const props = defineProps<{
|
|
server: ModrinthServer
|
|
}>()
|
|
|
|
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
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('name')
|
|
const sortDesc = ref(false)
|
|
|
|
const maxResults = 100
|
|
const currentPage = ref(1)
|
|
|
|
const currentPath = ref(typeof route.query.path === 'string' ? route.query.path : '')
|
|
|
|
const isAtBottom = ref(false)
|
|
const contextMenuInfo = ref<any>({ item: null, x: 0, y: 0 })
|
|
|
|
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)
|
|
const fileContent = ref('')
|
|
|
|
const isEditing = ref(false)
|
|
const editingFile = ref<any>(null)
|
|
const closeEditor = ref(false)
|
|
const isEditingImage = ref(false)
|
|
const imagePreview = ref()
|
|
|
|
const isDragging = ref(false)
|
|
|
|
const uploadDropdownRef = ref()
|
|
|
|
const data = computed(() => props.server.general)
|
|
|
|
const viewFilter = ref('all')
|
|
|
|
const handleFilter = (type: string) => {
|
|
viewFilter.value = type
|
|
sortMethod.value = 'name'
|
|
sortDesc.value = false
|
|
}
|
|
|
|
useHead({
|
|
title: computed(() => `Files - ${data.value?.name ?? 'Server'} - Modrinth`),
|
|
})
|
|
|
|
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
|
await modulesLoaded
|
|
|
|
const path = Array.isArray(currentPath.value) ? currentPath.value.join('') : currentPath.value
|
|
try {
|
|
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults)
|
|
|
|
if (!data || !data.items) {
|
|
throw new Error('Invalid data structure received from server.')
|
|
}
|
|
|
|
if (currentPage.value === 1) {
|
|
return {
|
|
items: data.items,
|
|
total: data.total,
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: [...(directoryData.value?.items || []), ...data.items],
|
|
total: data.total,
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching directory contents:', error)
|
|
if (error instanceof ModrinthServersFetchError && error.statusCode === 400) {
|
|
return directoryData.value || { items: [], total: 0 }
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const {
|
|
data: directoryData,
|
|
refresh: refreshData,
|
|
status,
|
|
error: loadError,
|
|
} = useLazyAsyncData(() => fetchDirectoryContents(), {
|
|
watch: [],
|
|
default: () => ({ items: [], total: 0 }),
|
|
immediate: true,
|
|
})
|
|
|
|
const isLoading = computed(() => status.value === 'pending')
|
|
|
|
const items = computed(() => directoryData.value?.items || [])
|
|
|
|
const refreshList = () => {
|
|
currentPage.value = 1
|
|
refreshData()
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
title: `${newItemType.value === 'directory' ? 'Folder' : 'File'} created`,
|
|
text: `New ${newItemType.value === 'directory' ? 'folder' : 'file'} ${name} has been created.`,
|
|
type: 'success',
|
|
})
|
|
} catch (error) {
|
|
handleCreateError(error)
|
|
}
|
|
}
|
|
|
|
const handleRenameItem = async (newName: string) => {
|
|
try {
|
|
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) {
|
|
await props.server.refresh()
|
|
isEditing.value = false
|
|
editingFile.value = null
|
|
closeEditor.value = false
|
|
router.push({ query: { ...route.query, path: currentPath.value } })
|
|
}
|
|
|
|
addNotification({
|
|
title: `${selectedItem.value.type === 'directory' ? 'Folder' : 'File'} renamed`,
|
|
text: `${selectedItem.value.name} has been renamed to ${newName}`,
|
|
type: 'success',
|
|
})
|
|
} catch (error) {
|
|
console.error('Error renaming item:', error)
|
|
if (error instanceof ModrinthServersFetchError) {
|
|
if (error.statusCode === 400) {
|
|
addNotification({
|
|
title: 'Could not rename',
|
|
text: `An item named "${newName}" already exists in this location`,
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
addNotification({
|
|
title: 'Could not rename item',
|
|
text: 'An unexpected error occurred',
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const extractItem = async (path: string) => {
|
|
try {
|
|
await props.server.fs?.extractFile(path, true, false)
|
|
} catch (error) {
|
|
console.error('Error extracting item:', error)
|
|
handleServersError(error, notifications)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
handleServersError(new Error('Error running dry run'), notifications)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error extracting item:', error)
|
|
handleServersError(error, notifications)
|
|
}
|
|
}
|
|
|
|
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(
|
|
`${sourcePath}/${selectedItem.value.name}`.replace('//', '/'),
|
|
newPath,
|
|
)
|
|
|
|
redoStack.value = []
|
|
operationHistory.value.push({
|
|
type: 'move',
|
|
sourcePath,
|
|
destinationPath: destination,
|
|
fileName: selectedItem.value.name,
|
|
itemType,
|
|
})
|
|
|
|
refreshList()
|
|
addNotification({
|
|
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({
|
|
title: `${moveData.type === 'directory' ? 'Folder' : 'File'} moved`,
|
|
text: `${moveData.name} has been moved to ${newPath}`,
|
|
type: 'success',
|
|
})
|
|
} catch (error) {
|
|
console.error('Error moving item:', error)
|
|
}
|
|
}
|
|
|
|
const handleDeleteItem = async () => {
|
|
try {
|
|
const path = `${currentPath.value}/${selectedItem.value.name}`.replace('//', '/')
|
|
await props.server.fs?.deleteFileOrFolder(path, selectedItem.value.type === 'directory')
|
|
|
|
refreshList()
|
|
addNotification({
|
|
title: 'File deleted',
|
|
text: 'Your file has been deleted.',
|
|
type: 'success',
|
|
})
|
|
} catch (error) {
|
|
console.error('Error deleting item:', error)
|
|
}
|
|
}
|
|
|
|
const showCreateModal = (type: 'file' | 'directory') => {
|
|
newItemType.value = type
|
|
createItemModal.value?.show()
|
|
}
|
|
|
|
const showUnzipFromUrlModal = (cf: boolean) => {
|
|
uploadZipModal.value?.show(cf)
|
|
}
|
|
|
|
const showRenameModal = (item: any) => {
|
|
selectedItem.value = item
|
|
renameItemModal.value?.show(item)
|
|
contextMenuInfo.value.item = null
|
|
}
|
|
|
|
const showMoveModal = (item: any) => {
|
|
selectedItem.value = item
|
|
moveItemModal.value?.show()
|
|
contextMenuInfo.value.item = null
|
|
}
|
|
|
|
const showDeleteModal = (item: any) => {
|
|
selectedItem.value = item
|
|
deleteItemModal.value?.show()
|
|
contextMenuInfo.value.item = null
|
|
}
|
|
|
|
const handleCreateError = (error: any) => {
|
|
console.error('Error creating item:', error)
|
|
if (error instanceof ModrinthServersFetchError) {
|
|
if (error.statusCode === 400) {
|
|
addNotification({
|
|
title: 'Error creating item',
|
|
text: 'Invalid file',
|
|
type: 'error',
|
|
})
|
|
} else if (error.statusCode === 500) {
|
|
addNotification({
|
|
title: 'Error creating item',
|
|
text: 'Something went wrong. The file may already exist.',
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleSort = (field: string) => {
|
|
if (sortMethod.value === field) {
|
|
sortDesc.value = !sortDesc.value
|
|
} else {
|
|
sortMethod.value = field
|
|
sortDesc.value = false
|
|
}
|
|
}
|
|
|
|
const applySort = (items: DirectoryItem[]) => {
|
|
let result = [...items]
|
|
|
|
switch (viewFilter.value) {
|
|
case 'filesOnly':
|
|
result = result.filter((item) => item.type !== 'directory')
|
|
break
|
|
case 'foldersOnly':
|
|
result = result.filter((item) => item.type === 'directory')
|
|
break
|
|
}
|
|
|
|
const compareItems = (a: DirectoryItem, b: DirectoryItem) => {
|
|
if (viewFilter.value === 'all') {
|
|
if (a.type === 'directory' && b.type !== 'directory') return -1
|
|
if (a.type !== 'directory' && b.type === 'directory') return 1
|
|
}
|
|
|
|
switch (sortMethod.value) {
|
|
case 'modified':
|
|
return sortDesc.value
|
|
? new Date(a.modified).getTime() - new Date(b.modified).getTime()
|
|
: new Date(b.modified).getTime() - new Date(a.modified).getTime()
|
|
case 'created':
|
|
return sortDesc.value
|
|
? new Date(a.created).getTime() - new Date(b.created).getTime()
|
|
: new Date(b.created).getTime() - new Date(a.created).getTime()
|
|
default:
|
|
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
|
}
|
|
}
|
|
|
|
result.sort(compareItems)
|
|
return result
|
|
}
|
|
|
|
const filteredItems = computed(() => {
|
|
let result = [...items.value]
|
|
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
result = result.filter((item) => item.name.toLowerCase().includes(query))
|
|
}
|
|
|
|
return applySort(result)
|
|
})
|
|
|
|
const { reset } = useInfiniteScroll(
|
|
scrollContainer,
|
|
async () => {
|
|
if (status.value === 'pending') return
|
|
|
|
try {
|
|
const totalPages = directoryData.value?.total || 0
|
|
|
|
if (currentPage.value < totalPages) {
|
|
currentPage.value++
|
|
const newData = await fetchDirectoryContents()
|
|
|
|
if (newData && newData.items) {
|
|
directoryData.value = {
|
|
items: [...directoryData.value.items, ...newData.items],
|
|
total: newData.total,
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during infinite scroll:', error)
|
|
}
|
|
},
|
|
{ distance: 1000 },
|
|
)
|
|
|
|
const handleLoadMore = async () => {
|
|
if (status.value === 'pending') return
|
|
|
|
const totalPages = directoryData.value?.total || 0
|
|
|
|
if (currentPage.value < totalPages) {
|
|
currentPage.value++
|
|
await refreshData()
|
|
}
|
|
}
|
|
|
|
const onInit = (editor: any) => {
|
|
editor.commands.addCommand({
|
|
name: 'saveFile',
|
|
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
|
exec: () => saveFileContent(false),
|
|
})
|
|
}
|
|
|
|
const showContextMenu = async (item: any, x: number, y: number) => {
|
|
contextMenuInfo.value = { item, x, y }
|
|
selectedItem.value = item
|
|
await nextTick()
|
|
if (!contextMenu.value?.ctxRef) return false
|
|
const screenHeight = window.innerHeight
|
|
const ctxRect = contextMenu.value.ctxRef.getBoundingClientRect()
|
|
isAtBottom.value = ctxRect.bottom > screenHeight
|
|
}
|
|
|
|
const onAnywhereClicked = (e: MouseEvent) => {
|
|
if (!(e.target as HTMLElement).closest('#item-context-menu')) {
|
|
contextMenuInfo.value.item = null
|
|
}
|
|
}
|
|
|
|
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp']
|
|
|
|
const editFile = async (item: { name: string; type: string; path: string }) => {
|
|
try {
|
|
const path = `${currentPath.value}/${item.name}`.replace('//', '/')
|
|
const content = (await props.server.fs?.downloadFile(path, true)) as any
|
|
window.scrollTo(0, 0)
|
|
|
|
fileContent.value = await content.text()
|
|
editingFile.value = item
|
|
isEditing.value = true
|
|
const extension = item.name.split('.').pop()
|
|
if (item.type === 'file' && extension && imageExtensions.includes(extension)) {
|
|
isEditingImage.value = true
|
|
imagePreview.value = content
|
|
}
|
|
router.push({ query: { ...route.query, path: currentPath.value, editing: item.path } })
|
|
} catch (error) {
|
|
console.error('Error fetching file content:', error)
|
|
}
|
|
}
|
|
|
|
const initializeFileEdit = async () => {
|
|
if (!route.query.editing || !props.server.fs) return
|
|
|
|
const filePath = route.query.editing as string
|
|
await editFile({
|
|
name: filePath.split('/').pop() || '',
|
|
type: 'file',
|
|
path: filePath,
|
|
})
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await modulesLoaded
|
|
|
|
await initializeFileEdit()
|
|
|
|
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()
|
|
}
|
|
})
|
|
|
|
props.server.fs?.clearQueuedOps()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', onAnywhereClicked)
|
|
window.removeEventListener('scroll', onScroll)
|
|
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) => {
|
|
currentPage.value = 1
|
|
searchQuery.value = ''
|
|
viewFilter.value = 'all'
|
|
sortMethod.value = 'name'
|
|
sortDesc.value = false
|
|
|
|
currentPath.value = Array.isArray(newQuery.path) ? newQuery.path.join('') : newQuery.path || '/'
|
|
|
|
if (newQuery.editing) {
|
|
await editFile({
|
|
name: newQuery.editing as string,
|
|
type: 'file',
|
|
path: newQuery.editing as string,
|
|
})
|
|
} else {
|
|
isEditing.value = false
|
|
editingFile.value = null
|
|
}
|
|
|
|
await refreshData()
|
|
reset()
|
|
},
|
|
{ immediate: true, deep: true },
|
|
)
|
|
|
|
const breadcrumbSegments = computed(() => {
|
|
if (typeof currentPath.value === 'string') {
|
|
return currentPath.value.split('/').filter(Boolean)
|
|
}
|
|
return []
|
|
})
|
|
|
|
const navigateToSegment = (index: number) => {
|
|
const newPath = breadcrumbSegments.value.slice(0, index + 1).join('/')
|
|
router.push({ query: { ...route.query, path: newPath } })
|
|
if (isEditing.value) {
|
|
isEditing.value = false
|
|
editingFile.value = null
|
|
closeEditor.value = false
|
|
|
|
const newQuery = { ...route.query }
|
|
delete newQuery.editing
|
|
router.replace({ query: newQuery })
|
|
}
|
|
}
|
|
|
|
// const navigateToPage = () => {
|
|
// router.push({ query: { path: currentPath.value } });
|
|
// };
|
|
|
|
const requestShareLink = async () => {
|
|
try {
|
|
const response = (await $fetch('https://api.mclo.gs/1/log', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ content: fileContent.value }),
|
|
})) as any
|
|
|
|
if (response.success) {
|
|
await navigator.clipboard.writeText(response.url)
|
|
addNotification({
|
|
title: 'Log URL copied',
|
|
text: 'Your log file URL has been copied to your clipboard.',
|
|
type: 'success',
|
|
})
|
|
} else {
|
|
throw new Error(response.error)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sharing file:', error)
|
|
}
|
|
}
|
|
|
|
const handleDroppedFiles = (files: File[]) => {
|
|
if (isEditing.value) return
|
|
|
|
files.forEach((file) => {
|
|
uploadDropdownRef.value?.uploadFile(file)
|
|
})
|
|
}
|
|
|
|
const initiateFileUpload = () => {
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.multiple = true
|
|
input.onchange = () => {
|
|
if (input.files) {
|
|
Array.from(input.files).forEach((file) => {
|
|
uploadDropdownRef.value?.uploadFile(file)
|
|
})
|
|
}
|
|
}
|
|
input.click()
|
|
}
|
|
|
|
const downloadFile = async (item: any) => {
|
|
if (item.type === 'file') {
|
|
try {
|
|
const path = `${currentPath.value}/${item.name}`.replace('//', '/')
|
|
const fileData = await props.server.fs?.downloadFile(path)
|
|
if (fileData) {
|
|
const blob = new Blob([fileData], { type: 'application/octet-stream' })
|
|
const link = document.createElement('a')
|
|
link.href = window.URL.createObjectURL(blob)
|
|
link.download = item.name
|
|
link.click()
|
|
window.URL.revokeObjectURL(link.href)
|
|
} else {
|
|
throw new Error('File data is undefined')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error downloading file:', error)
|
|
}
|
|
contextMenuInfo.value.item = null
|
|
}
|
|
}
|
|
|
|
const saveFileContent = async (exit: boolean = true) => {
|
|
if (!editingFile.value) return
|
|
|
|
try {
|
|
await props.server.fs?.updateFile(editingFile.value.path, fileContent.value)
|
|
if (exit) {
|
|
await props.server.refresh()
|
|
isEditing.value = false
|
|
editingFile.value = null
|
|
router.push({ query: { ...route.query, path: currentPath.value } })
|
|
}
|
|
|
|
addNotification({
|
|
title: 'File saved',
|
|
text: 'Your file has been saved.',
|
|
type: 'success',
|
|
})
|
|
} catch (error) {
|
|
console.error('Error saving file content:', error)
|
|
}
|
|
}
|
|
|
|
const saveFileContentRestart = async () => {
|
|
await saveFileContent()
|
|
await props.server.general?.power('Restart')
|
|
|
|
addNotification({
|
|
title: 'Server restarted',
|
|
text: 'Your server has been restarted.',
|
|
type: 'success',
|
|
})
|
|
}
|
|
|
|
const saveFileContentAs = async () => {
|
|
await saveFileContent(false)
|
|
closeEditor.value = true
|
|
showRenameModal(editingFile.value)
|
|
}
|
|
|
|
const cancelEditing = () => {
|
|
isEditing.value = false
|
|
editingFile.value = null
|
|
fileContent.value = ''
|
|
isEditingImage.value = false
|
|
imagePreview.value = null
|
|
router.push({ query: { ...route.query, path: currentPath.value } })
|
|
const newQuery = { ...route.query }
|
|
delete newQuery.editing
|
|
router.replace({ query: newQuery })
|
|
}
|
|
|
|
const onScroll = () => {
|
|
if (contextMenuInfo.value.item) {
|
|
contextMenuInfo.value.y = Math.max(0, contextMenuInfo.value.y - window.scrollY)
|
|
}
|
|
}
|
|
</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;
|
|
}
|
|
|
|
.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>
|