refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
729
packages/ui/src/layouts/shared/files-tab/layout.vue
Normal file
729
packages/ui/src/layouts/shared/files-tab/layout.vue
Normal file
@@ -0,0 +1,729 @@
|
||||
<template>
|
||||
<slot name="modals" />
|
||||
<FileUnsavedChangesModal ref="unsavedChangesModal" />
|
||||
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
||||
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
|
||||
<FileUploadZipUrlModal v-if="ctx.showInstallFromUrl" ref="uploadZipUrlModal" />
|
||||
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
||||
<FileMoveItemModal
|
||||
ref="moveItemModal"
|
||||
:item="selectedItem"
|
||||
:current-path="ctx.currentPath.value"
|
||||
@move="handleMoveItem"
|
||||
/>
|
||||
<FileDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
|
||||
<FileContextMenu ref="contextMenuRef">
|
||||
<template #extract
|
||||
><PackageOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon class="size-5" /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon class="size-5" /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon class="size-5" />
|
||||
{{ ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton) }}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</FileContextMenu>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="ctx.loading.value && items.length === 0"
|
||||
key="loading"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.loadingFiles) }}
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
||||
<template #header>{{ ctx.busyWarning.value }}</template>
|
||||
{{ formatMessage(messages.busyWarning) }}
|
||||
</Admonition>
|
||||
<FileOperationAdmonitions />
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div class="relative isolate flex w-full flex-col gap-4">
|
||||
<FileNavbar
|
||||
:breadcrumbs="breadcrumbSegments"
|
||||
:is-editing="isEditing"
|
||||
:editing-file-name="ctx.editingFile.value?.name"
|
||||
:editing-file-path="ctx.editingFile.value?.path"
|
||||
:is-editing-image="fileEditorRef?.isEditingImage"
|
||||
:search-query="searchQuery"
|
||||
:show-refresh-button="showRefreshButton"
|
||||
:show-install-from-url="ctx.showInstallFromUrl"
|
||||
:base-id="baseId"
|
||||
:disabled="isBusy"
|
||||
:disabled-tooltip="busyTooltip"
|
||||
@navigate="navigateToSegment"
|
||||
@navigate-home="() => navigateToSegment(-1)"
|
||||
@prefetch-home="handlePrefetchHome"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@upload-zip="() => {}"
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@refresh="ctx.refresh"
|
||||
@share="() => fileEditorRef?.shareToMclogs()"
|
||||
/>
|
||||
|
||||
<div v-if="!isEditing">
|
||||
<FileUploadDragAndDrop
|
||||
ref="fileUploadRef"
|
||||
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<FileTableHeader
|
||||
:sort-field="sortField"
|
||||
:sort-desc="sortDescValue"
|
||||
:all-selected="allSelected"
|
||||
:some-selected="someSelected"
|
||||
:is-stuck="isLabelBarStuck"
|
||||
@sort="handleSort"
|
||||
@toggle-all="toggleSelectAll"
|
||||
/>
|
||||
<div
|
||||
v-if="filteredItems.length > 0"
|
||||
ref="virtualListContainer"
|
||||
class="relative w-full"
|
||||
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
||||
<FileTableRow
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === filteredItems.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
:write-disabled="isBusy"
|
||||
:write-disabled-tooltip="busyTooltip"
|
||||
@extract="() => handleExtractItem(item)"
|
||||
@delete="() => showDeleteModal(item)"
|
||||
@rename="() => showRenameModal(item)"
|
||||
@download="() => handleDownload(item)"
|
||||
@move="() => showMoveModal(item)"
|
||||
@move-direct-to="handleDirectMove"
|
||||
@edit="() => handleEditFile(item)"
|
||||
@navigate="() => handleNavigateToFolder(item)"
|
||||
@hover="() => handleItemHover(item)"
|
||||
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
||||
@toggle-select="() => toggleItemSelection(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && !ctx.error.value"
|
||||
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 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">
|
||||
{{ formatMessage(messages.emptyFolderTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.emptyFolderDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FileManagerError
|
||||
v-else-if="ctx.error.value"
|
||||
class="rounded-b-[20px]"
|
||||
:title="formatMessage(messages.errorTitle)"
|
||||
:message="formatMessage(messages.errorMessage)"
|
||||
@refetch="ctx.refresh"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
</FileUploadDragAndDrop>
|
||||
</div>
|
||||
<FileEditor
|
||||
v-else
|
||||
ref="fileEditorRef"
|
||||
:file="ctx.editingFile.value"
|
||||
:editor-component="editorComponent"
|
||||
@close="handleEditorClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar :shown="hasUnsavedChanges">
|
||||
<p class="m-0 text-sm font-semibold md:text-base">
|
||||
{{ formatMessage(messages.unsavedChanges) }}
|
||||
</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="fileEditorRef?.revertChanges()">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="fileEditorRef?.saveFileContent(false)">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<FloatingActionBar :shown="selectedItems.size > 0">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button class="!text-primary" @click="deselectAll">
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
HistoryIcon,
|
||||
PackageOpenIcon,
|
||||
RightArrowIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
||||
import { useVirtualScroll } from '#ui/composables/virtual-scroll'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { getFileExtension } from '#ui/utils/file-extensions'
|
||||
|
||||
import FileEditor from './components/editor/FileEditor.vue'
|
||||
import FileContextMenu from './components/FileContextMenu.vue'
|
||||
import FileManagerError from './components/FileManagerError.vue'
|
||||
import FileNavbar from './components/FileNavbar.vue'
|
||||
import FileOperationAdmonitions from './components/FileOperationAdmonitions.vue'
|
||||
import FileTableHeader from './components/FileTableHeader.vue'
|
||||
import FileTableRow from './components/FileTableRow.vue'
|
||||
import FileCreateItemModal from './components/modals/FileCreateItemModal.vue'
|
||||
import FileDeleteItemModal from './components/modals/FileDeleteItemModal.vue'
|
||||
import FileMoveItemModal from './components/modals/FileMoveItemModal.vue'
|
||||
import FileRenameItemModal from './components/modals/FileRenameItemModal.vue'
|
||||
import FileUnsavedChangesModal from './components/modals/FileUnsavedChangesModal.vue'
|
||||
import FileUploadConflictModal from './components/modals/FileUploadConflictModal.vue'
|
||||
import FileUploadZipUrlModal from './components/modals/FileUploadZipUrlModal.vue'
|
||||
import FileUploadDragAndDrop from './components/upload/FileUploadDragAndDrop.vue'
|
||||
import { useFileSearch } from './composables/file-search'
|
||||
import { useFileSelection } from './composables/file-selection'
|
||||
import { useFileSorting } from './composables/file-sorting'
|
||||
import { useFileUndoRedo } from './composables/file-undo-redo'
|
||||
import { injectFileManager } from './providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from './types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingFiles: {
|
||||
id: 'files.layout.loading',
|
||||
defaultMessage: 'Loading files...',
|
||||
},
|
||||
busyWarning: {
|
||||
id: 'files.layout.busy-warning',
|
||||
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
||||
},
|
||||
emptyFolderTitle: {
|
||||
id: 'files.layout.empty-folder-title',
|
||||
defaultMessage: 'This folder is empty',
|
||||
},
|
||||
emptyFolderDescription: {
|
||||
id: 'files.layout.empty-folder-description',
|
||||
defaultMessage: 'There are no files or folders.',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'files.layout.error-title',
|
||||
defaultMessage: 'Unable to load files',
|
||||
},
|
||||
errorMessage: {
|
||||
id: 'files.layout.error-message',
|
||||
defaultMessage: 'The folder may not exist.',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'files.layout.selected-count',
|
||||
defaultMessage: '{count} selected',
|
||||
},
|
||||
dryRunFailedTitle: {
|
||||
id: 'files.layout.dry-run-failed-title',
|
||||
defaultMessage: 'Dry run failed',
|
||||
},
|
||||
dryRunFailedText: {
|
||||
id: 'files.layout.dry-run-failed-text',
|
||||
defaultMessage: 'Error running dry run',
|
||||
},
|
||||
extractionStartedTitle: {
|
||||
id: 'files.layout.extraction-started-title',
|
||||
defaultMessage: 'Extraction started',
|
||||
},
|
||||
unsavedChanges: {
|
||||
id: 'files.layout.unsaved-changes',
|
||||
defaultMessage: 'You have unsaved changes.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
showDebugInfo?: boolean
|
||||
showRefreshButton?: boolean
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const editorComponent = shallowRef<Component | null>(null)
|
||||
import('vue3-ace-editor').then(async (mod) => {
|
||||
await Promise.all([import('#ui/utils/ace-theme'), import('#ui/utils/ace-mode-log.ts')])
|
||||
editorComponent.value = mod.VAceEditor
|
||||
})
|
||||
|
||||
const baseId = `files-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const items = computed(() => ctx.items.value)
|
||||
const isEditing = computed(() => ctx.editingFile.value !== null)
|
||||
const isBusy = computed(() => ctx.isBusy?.value ?? false)
|
||||
const busyTooltip = computed(() => ctx.busyTooltip?.value)
|
||||
|
||||
const breadcrumbSegments = computed(() => {
|
||||
const path = ctx.currentPath.value
|
||||
if (typeof path === 'string') {
|
||||
return path.split('/').filter(Boolean)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Composables
|
||||
const { searchQuery, searchedItems } = useFileSearch(items)
|
||||
const {
|
||||
sortField,
|
||||
sortDesc: sortDescValue,
|
||||
handleSort,
|
||||
sortedItems: filteredItems,
|
||||
resetSort,
|
||||
} = useFileSorting(searchedItems)
|
||||
|
||||
const {
|
||||
selectedItems,
|
||||
toggleItemSelection,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
} = useFileSelection(filteredItems)
|
||||
|
||||
const { recordOperation, onKeydown } = useFileUndoRedo(
|
||||
(path, newName) => ctx.renameItem(path, newName),
|
||||
(source, dest) => ctx.moveItem(source, dest),
|
||||
() => ctx.refresh(),
|
||||
(title, text, type) => addNotification({ title, text, type }),
|
||||
)
|
||||
|
||||
// Virtual scroll
|
||||
const {
|
||||
listContainer: virtualListContainer,
|
||||
totalHeight,
|
||||
visibleRange,
|
||||
visibleTop,
|
||||
visibleItems,
|
||||
} = useVirtualScroll(filteredItems, {
|
||||
itemHeight: 61,
|
||||
bufferSize: 5,
|
||||
})
|
||||
|
||||
// Sticky observer for the table header
|
||||
const fileUploadRef = ref<InstanceType<typeof FileUploadDragAndDrop>>()
|
||||
const fileUploadEl = computed(() => fileUploadRef.value?.$el as HTMLElement | null)
|
||||
const { isStuck: isLabelBarStuck } = useStickyObserver(fileUploadEl)
|
||||
|
||||
// Refs
|
||||
const fileEditorRef = ref<InstanceType<typeof FileEditor>>()
|
||||
const createItemModal = ref<InstanceType<typeof FileCreateItemModal>>()
|
||||
const renameItemModal = ref<InstanceType<typeof FileRenameItemModal>>()
|
||||
const moveItemModal = ref<InstanceType<typeof FileMoveItemModal>>()
|
||||
const deleteItemModal = ref<InstanceType<typeof FileDeleteItemModal>>()
|
||||
const uploadConflictModal = ref<InstanceType<typeof FileUploadConflictModal>>()
|
||||
const uploadZipUrlModal = ref<InstanceType<typeof FileUploadZipUrlModal>>()
|
||||
const contextMenuRef = ref<InstanceType<typeof FileContextMenu>>()
|
||||
|
||||
const newItemType = ref<'file' | 'directory'>('file')
|
||||
const selectedItem = ref<FileItem | null>(null)
|
||||
|
||||
const unsavedChangesModal = ref<InstanceType<typeof FileUnsavedChangesModal>>()
|
||||
|
||||
const hasUnsavedChanges = computed(() => fileEditorRef.value?.hasUnsavedChanges ?? false)
|
||||
|
||||
async function confirmDiscardChanges(): Promise<boolean> {
|
||||
if (!hasUnsavedChanges.value) return true
|
||||
const result = await unsavedChangesModal.value?.prompt()
|
||||
if (result === 'save') {
|
||||
await fileEditorRef.value?.saveFileContent(false)
|
||||
return true
|
||||
}
|
||||
return result === 'discard'
|
||||
}
|
||||
|
||||
// Navigation
|
||||
async function navigateToSegment(index: number) {
|
||||
const newPath = index === -1 ? '/' : breadcrumbSegments.value.slice(0, index + 1).join('/')
|
||||
|
||||
if (newPath === ctx.currentPath.value && !isEditing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
if (!(await confirmDiscardChanges())) return
|
||||
ctx.stopEditing()
|
||||
}
|
||||
|
||||
ctx.navigateTo(newPath)
|
||||
}
|
||||
|
||||
function handleNavigateToFolder(item: FileItem) {
|
||||
const currentPath = ctx.currentPath.value
|
||||
const newPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${item.name}`
|
||||
: `${currentPath}/${item.name}`
|
||||
ctx.navigateTo(newPath)
|
||||
}
|
||||
|
||||
// Editing
|
||||
function handleEditFile(item: { name: string; type: string; path: string }) {
|
||||
ctx.startEditing({ name: item.name, path: item.path })
|
||||
}
|
||||
|
||||
async function handleEditorClose() {
|
||||
if (!(await confirmDiscardChanges())) return
|
||||
ctx.stopEditing()
|
||||
}
|
||||
|
||||
// CRUD handlers
|
||||
async function handleCreateNewItem(name: string) {
|
||||
await ctx.createItem(name, newItemType.value)
|
||||
}
|
||||
|
||||
async function handleRenameItem(newName: string) {
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
const path = `${ctx.currentPath.value}/${item.name}`.replace('//', '/')
|
||||
await ctx.renameItem(path, newName)
|
||||
recordOperation({
|
||||
type: 'rename',
|
||||
itemType: item.type,
|
||||
fileName: item.name,
|
||||
path: ctx.currentPath.value,
|
||||
oldName: item.name,
|
||||
newName,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMoveItem(destination: string) {
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
const sourcePath = ctx.currentPath.value
|
||||
const source = `${sourcePath}/${item.name}`.replace('//', '/')
|
||||
const dest = `${destination}/${item.name}`.replace('//', '/')
|
||||
|
||||
await ctx.moveItem(source, dest)
|
||||
recordOperation({
|
||||
type: 'move',
|
||||
sourcePath,
|
||||
destinationPath: destination,
|
||||
fileName: item.name,
|
||||
itemType: item.type,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDeleteItem() {
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
const path = `${ctx.currentPath.value}/${item.name}`.replace('//', '/')
|
||||
ctx.deleteItem(path, item.type === 'directory')
|
||||
}
|
||||
|
||||
function handleDirectMove(moveData: {
|
||||
name: string
|
||||
type: string
|
||||
path: string
|
||||
destination: string
|
||||
}) {
|
||||
if (isBusy.value) return
|
||||
const dest = `${moveData.destination}/${moveData.name}`.replace('//', '/')
|
||||
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf('/'))
|
||||
|
||||
ctx.moveItem(moveData.path, dest).then(() => {
|
||||
recordOperation({
|
||||
type: 'move',
|
||||
sourcePath,
|
||||
destinationPath: moveData.destination,
|
||||
fileName: moveData.name,
|
||||
itemType: moveData.type,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Download
|
||||
async function handleDownload(item: FileItem) {
|
||||
if (item.type === 'file') {
|
||||
await ctx.downloadFile(item.path, item.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract
|
||||
async function handleExtractItem(item: { name: string; type: string; path: string }) {
|
||||
if (isBusy.value || !ctx.extractFile) return
|
||||
try {
|
||||
const dry = await ctx.extractFile(item.path, true, true)
|
||||
if (dry) {
|
||||
if (dry.conflicting_files.length === 0) {
|
||||
handleExtractConfirm(item.path)
|
||||
} else {
|
||||
uploadConflictModal.value?.show(item.path, dry.conflicting_files)
|
||||
}
|
||||
} else {
|
||||
addNotification({
|
||||
title: formatMessage(messages.dryRunFailedTitle),
|
||||
text: formatMessage(messages.dryRunFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: error instanceof Error ? error.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtractConfirm(path: string) {
|
||||
if (!ctx.extractFile) return
|
||||
try {
|
||||
await ctx.extractFile(path, true, false)
|
||||
addNotification({ title: formatMessage(messages.extractionStartedTitle), type: 'success' })
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: error instanceof Error ? error.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Modal show helpers
|
||||
function showCreateModal(type: 'file' | 'directory') {
|
||||
if (isBusy.value) return
|
||||
newItemType.value = type
|
||||
createItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showUnzipFromUrlModal(cf: boolean) {
|
||||
if (isBusy.value) return
|
||||
uploadZipUrlModal.value?.show(cf)
|
||||
}
|
||||
|
||||
function showRenameModal(item: FileItem) {
|
||||
if (isBusy.value) return
|
||||
selectedItem.value = item
|
||||
renameItemModal.value?.show(item)
|
||||
}
|
||||
|
||||
function showMoveModal(item: FileItem) {
|
||||
if (isBusy.value) return
|
||||
selectedItem.value = item
|
||||
moveItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showDeleteModal(item: FileItem) {
|
||||
if (isBusy.value) return
|
||||
selectedItem.value = item
|
||||
deleteItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showBulkDeleteModal() {
|
||||
if (isBusy.value) return
|
||||
if (selectedItems.value.size === 0) return
|
||||
|
||||
const itemsToDelete = Array.from(selectedItems.value)
|
||||
for (const path of itemsToDelete) {
|
||||
const item = items.value.find((i) => i.path === path)
|
||||
if (item) {
|
||||
ctx.deleteItem(path, item.type === 'directory')
|
||||
}
|
||||
}
|
||||
deselectAll()
|
||||
}
|
||||
|
||||
// Upload
|
||||
function handleDroppedFiles(files: File[]) {
|
||||
if (isEditing.value || isBusy.value) return
|
||||
ctx.uploadFiles(files)
|
||||
}
|
||||
|
||||
function initiateFileUpload() {
|
||||
if (isBusy.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
ctx.uploadFiles(Array.from(input.files))
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// Prefetch
|
||||
let prefetchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let prefetchHomeTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleItemHover(item: { type: string; path: string; name: string }) {
|
||||
if (prefetchTimeout) {
|
||||
clearTimeout(prefetchTimeout)
|
||||
prefetchTimeout = null
|
||||
}
|
||||
|
||||
if (item.type === 'directory') {
|
||||
prefetchTimeout = setTimeout(() => {
|
||||
const currentPath = ctx.currentPath.value
|
||||
const navPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${item.name}`
|
||||
: `${currentPath}/${item.name}`
|
||||
ctx.prefetchDirectory?.(navPath)
|
||||
}, 150)
|
||||
} else {
|
||||
prefetchTimeout = setTimeout(() => {
|
||||
ctx.prefetchFile?.(item.path)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrefetchHome() {
|
||||
if (prefetchHomeTimeout) {
|
||||
clearTimeout(prefetchHomeTimeout)
|
||||
prefetchHomeTimeout = null
|
||||
}
|
||||
prefetchHomeTimeout = setTimeout(() => {
|
||||
ctx.prefetchDirectory?.('/')
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// Context menu
|
||||
function handleContextMenu(item: FileItem, x: number, y: number) {
|
||||
const wd = isBusy.value
|
||||
const wdTooltip = busyTooltip.value
|
||||
const isZip = getFileExtension(item.name) === 'zip'
|
||||
|
||||
const options: FileContextMenuOption[] = [
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip && !!ctx.extractFile,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => handleExtractItem(item),
|
||||
},
|
||||
{ divider: true, shown: isZip && !!ctx.extractFile },
|
||||
{
|
||||
id: 'rename',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showRenameModal(item),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showMoveModal(item),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => handleDownload(item),
|
||||
shown: item.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showDeleteModal(item),
|
||||
color: 'red',
|
||||
},
|
||||
]
|
||||
|
||||
contextMenuRef.value?.show(item, x, y, options)
|
||||
}
|
||||
|
||||
// Reset search/sort/selection on path change
|
||||
watch(
|
||||
() => ctx.currentPath.value,
|
||||
() => {
|
||||
searchQuery.value = ''
|
||||
resetSort()
|
||||
deselectAll()
|
||||
},
|
||||
)
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user