Files
AstralRinth/packages/ui/src/layouts/shared/files-tab/layout.vue
T
Truman Gao 693a371d61 feat: server management in app (#5628)
* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-04-12 21:38:08 +00:00

728 lines
22 KiB
Vue

<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>
<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 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>