Files
Rocketmc/packages/ui/src/pages/hosting/manage/files.vue
2026-01-16 09:03:56 +00:00

1328 lines
36 KiB
Vue

<template>
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
<FileUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
<FileMoveItemModal
ref="moveItemModal"
:item="selectedItem"
:current-path="currentPath"
@move="handleMoveItem"
/>
<FileDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
<Transition name="fade" mode="out-in">
<div
v-if="isLoading && 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" />
Loading files...
</div>
<div v-else key="content" class="contents">
<div class="relative -mt-2 flex w-full flex-col">
<div class="relative isolate flex w-full flex-col gap-2">
<FileNavbar
:breadcrumbs="breadcrumbSegments"
:is-editing="isEditing"
:editing-file-name="editingFile?.name"
:editing-file-path="editingFile?.path"
:is-editing-image="fileEditorRef?.isEditingImage"
:search-query="searchQuery"
:base-id="baseId"
@navigate="navigateToSegment"
@navigate-home="() => navigateToSegment(-1)"
@prefetch-home="handlePrefetchHome"
@update:search-query="searchQuery = $event"
@create="showCreateModal"
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@save="() => fileEditorRef?.saveFileContent(true)"
@save-as="() => fileEditorRef?.saveFileContent(false)"
@save-restart="() => fileEditorRef?.saveAndRestart()"
@share="() => fileEditorRef?.shareToMclogs()"
/>
<div v-if="!isEditing" class="contents">
<div ref="labelBarSentinel" class="h-0 w-full" aria-hidden="true" />
<FileUploadDragAndDrop
class="relative flex flex-col shadow-md"
@files-dropped="handleDroppedFiles"
>
<FileLabelBar
:sort-field="sortMethod"
:sort-desc="sortDesc"
:all-selected="allSelected"
:some-selected="someSelected"
:is-stuck="isLabelBarStuck"
@sort="handleSort"
@toggle-all="toggleSelectAll"
/>
<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="
() => {
if ('id' in op && op.id) {
dismissOrCancelOp(op.id, op.state === 'done' ? 'dismiss' : 'cancel')
}
}
"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<pre
v-if="showDebugInfo"
class="markdown-body col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ op }}</pre
>
</div>
<FileUploadDropdown
ref="uploadDropdownRef"
class="border-0 border-t border-solid border-bg bg-table-alternateRow"
:current-path="currentPath"
@upload-complete="refreshList()"
/>
<Transition name="fade" mode="out-in">
<div v-if="items.length > 0" key="list" class="h-full w-full overflow-hidden">
<FileVirtualList
:items="filteredItems"
:selected-items="selectedItems"
@extract="handleExtractItem"
@delete="showDeleteModal"
@rename="showRenameModal"
@download="downloadFile"
@move="showMoveModal"
@move-direct-to="handleDirectMove"
@edit="editFile"
@hover="handleItemHover"
@load-more="handleLoadMore"
@toggle-select="toggleItemSelection"
/>
</div>
<div
v-else-if="items.length === 0 && !loadError"
key="empty"
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">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"
key="error"
class="rounded-b-[20px]"
title="Unable to load files"
message="The folder may not exist."
@refetch="refreshList"
@home="navigateToSegment(-1)"
/>
</Transition>
</FileUploadDragAndDrop>
</div>
<FileEditor
v-else
ref="fileEditorRef"
:file="editingFile"
:editor-component="VAceEditor"
@close="handleEditorClose"
/>
</div>
</div>
<FloatingActionBar :shown="selectedItems.size > 0">
<ButtonStyled circular>
<button @click="deselectAll">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<span class="text-sm font-medium text-contrast"> {{ selectedItems.size }} selected </span>
<div class="ml-auto flex items-center gap-2">
<ButtonStyled>
<button @click="showBulkMoveModal">
<RightArrowIcon class="h-4 w-4" />
Move
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="showBulkDeleteModal">
<TrashIcon class="h-4 w-4" />
Delete
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { Archon, Kyros } from '@modrinth/api-client'
import {
CheckIcon,
FolderOpenIcon,
PackageOpenIcon,
RightArrowIcon,
SpinnerIcon,
TrashIcon,
UnknownIcon,
XIcon,
} from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, inject, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ButtonStyled from '../../../components/base/ButtonStyled.vue'
import FloatingActionBar from '../../../components/base/FloatingActionBar.vue'
import ProgressBar from '../../../components/base/ProgressBar.vue'
import {
FileEditor,
FileLabelBar,
FileManagerError,
FileNavbar,
FileUploadDragAndDrop,
FileUploadDropdown,
FileVirtualList,
} from '../../../components/servers/files'
import {
FileCreateItemModal,
FileDeleteItemModal,
FileMoveItemModal,
FileRenameItemModal,
FileUploadConflictModal,
} from '../../../components/servers/files/modals'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import {
getFileExtension,
isEditableFile as isEditableFileCheck,
} from '../../../utils/file-extensions'
defineProps<{
showDebugInfo?: boolean
}>()
const notifications = injectNotificationManager()
const { addNotification } = notifications
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId, fsOps, fsQueuedOps } = serverContext
const queryClient = useQueryClient()
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 InfiniteDirectoryData {
pages: Kyros.Files.v0.DirectoryResponse[]
pageParams: number[]
}
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const route = useRoute()
const router = useRouter()
const baseId = `files-${Math.random().toString(36).slice(2, 9)}`
const operationHistory = ref<Operation[]>([])
const redoStack = ref<Operation[]>([])
const searchQuery = ref('')
const sortMethod = ref('name')
const sortDesc = ref(false)
const selectedItems = ref<Set<string>>(new Set())
function toggleItemSelection(path: string) {
const newSet = new Set(selectedItems.value)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
selectedItems.value = newSet
}
function selectAll() {
selectedItems.value = new Set(filteredItems.value.map((i) => i.path))
}
function deselectAll() {
selectedItems.value = new Set()
}
function toggleSelectAll() {
if (allSelected.value) {
deselectAll()
} else {
selectAll()
}
}
const allSelected = computed(
() => filteredItems.value.length > 0 && selectedItems.value.size === filteredItems.value.length,
)
const someSelected = computed(
() => selectedItems.value.size > 0 && selectedItems.value.size < filteredItems.value.length,
)
const currentPath = computed(() => (typeof route.query.path === 'string' ? route.query.path : '/'))
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 newItemType = ref<'file' | 'directory'>('file')
const selectedItem = ref<Kyros.Files.v0.DirectoryItem | null>(null)
const isEditing = ref(false)
const editingFile = ref<{ name: string; type: string; path: string } | null>(null)
const fileEditorRef = ref<InstanceType<typeof FileEditor>>()
const uploadDropdownRef = ref<InstanceType<typeof FileUploadDropdown>>()
const VAceEditor = ref()
const labelBarSentinel = ref<HTMLDivElement>()
const isLabelBarStuck = ref(false)
let labelBarObserver: IntersectionObserver | null = null
const viewFilter = ref('all')
const {
data: directoryData,
isLoading,
error: loadError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: computed(() => ['files', serverId, currentPath.value]),
queryFn: async ({ pageParam = 1 }) => {
if (modulesLoaded) await modulesLoaded
return client.kyros.files_v0.listDirectory(currentPath.value, pageParam, 100)
},
getNextPageParam: (lastPage, allPages) => {
const pageSize = 100
if (lastPage.items.length >= pageSize) {
return allPages.length + 1
}
if (lastPage.current < lastPage.total) {
return lastPage.current + 1
}
return undefined
},
staleTime: 30_000,
initialPageParam: 1,
})
const items = computed(() => directoryData.value?.pages.flatMap((page) => page.items) ?? [])
function prefetchDirectory(path: string) {
queryClient.prefetchInfiniteQuery({
queryKey: ['files', serverId, path],
queryFn: async () => {
if (modulesLoaded) await modulesLoaded
try {
return await client.kyros.files_v0.listDirectory(path, 1, 100)
} catch {
return { items: [], total: 0, current: 1 }
}
},
initialPageParam: 1,
staleTime: 30_000,
})
}
let prefetchTimeout: ReturnType<typeof setTimeout> | null = null
let prefetchHomeTimeout: ReturnType<typeof setTimeout> | null = null
function handlePrefetchHome() {
if (prefetchHomeTimeout) {
clearTimeout(prefetchHomeTimeout)
prefetchHomeTimeout = null
}
prefetchHomeTimeout = setTimeout(() => {
prefetchDirectory('/')
}, 150)
}
function prefetchFileContent(path: string) {
queryClient.prefetchQuery({
queryKey: ['file-content', serverId, path],
queryFn: async () => {
if (modulesLoaded) await modulesLoaded
try {
const blob = await client.kyros.files_v0.downloadFile(path)
return await blob.text()
} catch {
return null
}
},
staleTime: 30_000,
})
}
function handleItemHover(item: { type: string; path: string; name: string }) {
if (prefetchTimeout) {
clearTimeout(prefetchTimeout)
prefetchTimeout = null
}
if (item.type === 'directory') {
prefetchTimeout = setTimeout(() => {
const routePath = typeof route.query.path === 'string' ? route.query.path : ''
const navPath = routePath.endsWith('/')
? `${routePath}${item.name}`
: `${routePath}/${item.name}`
prefetchDirectory(navPath)
}, 150)
} else if (item.type === 'file') {
const ext = getFileExtension(item.name)
if (isEditableFileCheck(ext)) {
prefetchTimeout = setTimeout(() => {
prefetchFileContent(item.path)
}, 150)
}
}
}
function getQueryKey() {
return ['files', serverId, currentPath.value]
}
const deleteMutation = useMutation({
mutationFn: ({ path, recursive }: { path: string; recursive: boolean }) =>
client.kyros.files_v0.deleteFileOrFolder(path, recursive),
onMutate: async ({ path }) => {
const queryKey = getQueryKey()
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData(queryKey)
queryClient.setQueryData(queryKey, (old: InfiniteDirectoryData | undefined) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.path !== path),
total: Math.max(0, page.total - 1),
})),
}
})
return { previous }
},
onError: (err: Error, _vars, context) => {
queryClient.setQueryData(getQueryKey(), context?.previous)
addNotification({ title: 'Delete failed', text: err.message, type: 'error' })
},
onSuccess: () => {
addNotification({ title: 'File deleted', text: 'Your file has been deleted.', type: 'success' })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
},
})
const renameMutation = useMutation({
mutationFn: ({ path, newName }: { path: string; newName: string }) =>
client.kyros.files_v0.renameFileOrFolder(path, newName),
onMutate: async ({ path, newName }) => {
const queryKey = getQueryKey()
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData(queryKey)
queryClient.setQueryData(queryKey, (old: InfiniteDirectoryData | undefined) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.path === path
? { ...item, name: newName, path: item.path.replace(/[^/]+$/, newName) }
: item,
),
})),
}
})
return { previous }
},
onError: (err: Error, _vars, context) => {
queryClient.setQueryData(getQueryKey(), context?.previous)
addNotification({ title: 'Rename failed', text: err.message, type: 'error' })
},
onSuccess: (_, { newName }) => {
addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
},
})
const moveMutation = useMutation({
mutationFn: ({ source, destination }: { source: string; destination: string }) =>
client.kyros.files_v0.moveFileOrFolder(source, destination),
onMutate: async ({ source }) => {
const queryKey = getQueryKey()
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData(queryKey)
queryClient.setQueryData(queryKey, (old: InfiniteDirectoryData | undefined) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.path !== source),
total: Math.max(0, page.total - 1),
})),
}
})
return { previous }
},
onError: (err: Error, _vars, context) => {
queryClient.setQueryData(getQueryKey(), context?.previous)
addNotification({ title: 'Move failed', text: err.message, type: 'error' })
},
onSuccess: (_, { destination }) => {
addNotification({ title: 'Moved', text: `Moved to ${destination}`, type: 'success' })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
},
})
const createMutation = useMutation({
mutationFn: ({ path, type }: { path: string; type: 'file' | 'directory' }) =>
client.kyros.files_v0.createFileOrFolder(path, type),
onMutate: async ({ path, type }) => {
const queryKey = getQueryKey()
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData(queryKey)
const name = path.split('/').pop()!
const now = Math.floor(Date.now() / 1000)
const newItem: Kyros.Files.v0.DirectoryItem = {
name,
path,
type,
modified: now,
created: now,
...(type === 'directory' ? { count: 0 } : { size: 0 }),
}
queryClient.setQueryData(queryKey, (old: InfiniteDirectoryData | undefined) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page, i) =>
i === 0
? {
...page,
items: [newItem, ...page.items],
total: page.total + 1,
}
: page,
),
}
})
return { previous }
},
onError: (err: Error, _vars, context) => {
queryClient.setQueryData(getQueryKey(), context?.previous)
addNotification({ title: 'Create failed', text: err.message, type: 'error' })
},
onSuccess: (_, { path, type }) => {
const name = path.split('/').pop()
addNotification({
title: `${type === 'directory' ? 'Folder' : 'File'} created`,
text: `Created ${name}`,
type: 'success',
})
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
},
})
const extractMutation = useMutation({
mutationFn: ({ path, override }: { path: string; override: boolean }) =>
client.kyros.files_v0.extractFile(path, override, false),
onSuccess: () => {
addNotification({ title: 'Extraction started', type: 'success' })
},
onError: (err: Error) => {
addNotification({ title: 'Extract failed', text: err.message, type: 'error' })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
},
})
function refreshList() {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
}
async function undoLastOperation() {
const lastOperation = operationHistory.value.pop()
if (!lastOperation) return
try {
switch (lastOperation.type) {
case 'move':
await client.kyros.files_v0.moveFileOrFolder(
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
)
break
case 'rename':
await client.kyros.files_v0.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',
})
}
}
async function redoLastOperation() {
const lastOperation = redoStack.value.pop()
if (!lastOperation) return
try {
switch (lastOperation.type) {
case 'move':
await client.kyros.files_v0.moveFileOrFolder(
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
)
break
case 'rename':
await client.kyros.files_v0.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',
})
}
}
function handleCreateNewItem(name: string) {
const path = `${currentPath.value}/${name}`.replace('//', '/')
createMutation.mutate({ path, type: newItemType.value })
}
function handleRenameItem(newName: string) {
const item = selectedItem.value
if (!item) return
const path = `${currentPath.value}/${item.name}`.replace('//', '/')
renameMutation.mutate(
{ path, newName },
{
onSuccess: () => {
redoStack.value = []
operationHistory.value.push({
type: 'rename',
itemType: item.type,
fileName: item.name,
path: currentPath.value,
oldName: item.name,
newName,
})
},
},
)
}
const localQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
function extractItem(path: string) {
localQueuedOps.value.push({ op: 'unarchive', src: path })
setTimeout(() => {
localQueuedOps.value = localQueuedOps.value.filter(
(x) => x.op !== 'unarchive' || x.src !== path,
)
}, 4000)
extractMutation.mutate(
{ path, override: true },
{
onError: () => {
localQueuedOps.value = localQueuedOps.value.filter(
(x) => x.op !== 'unarchive' || x.src !== path,
)
},
},
)
}
async function handleExtractItem(item: { name: string; type: string; path: string }) {
try {
const dry = await client.kyros.files_v0.extractFile(item.path, true, true)
if (dry) {
if (dry.conflicting_files.length === 0) {
extractItem(item.path)
} else {
uploadConflictModal.value?.show(item.path, dry.conflicting_files)
}
} else {
addNotification({ title: 'Dry run failed', text: 'Error running dry run', type: 'error' })
}
} catch (error) {
console.error('Error extracting item:', error)
addNotification({
title: 'Extract failed',
text: error instanceof Error ? error.message : 'Unknown error',
type: 'error',
})
}
}
function handleMoveItem(destination: string) {
const item = selectedItem.value
if (!item) return
const sourcePath = currentPath.value
const source = `${sourcePath}/${item.name}`.replace('//', '/')
const dest = `${destination}/${item.name}`.replace('//', '/')
moveMutation.mutate(
{ source, destination: dest },
{
onSuccess: () => {
redoStack.value = []
operationHistory.value.push({
type: 'move',
sourcePath,
destinationPath: destination,
fileName: item.name,
itemType: item.type,
})
},
},
)
}
function handleDirectMove(moveData: {
name: string
type: string
path: string
destination: string
}) {
const dest = `${moveData.destination}/${moveData.name}`.replace('//', '/')
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf('/'))
moveMutation.mutate(
{ source: moveData.path, destination: dest },
{
onSuccess: () => {
redoStack.value = []
operationHistory.value.push({
type: 'move',
sourcePath,
destinationPath: moveData.destination,
fileName: moveData.name,
itemType: moveData.type,
})
},
},
)
}
function handleDeleteItem() {
const item = selectedItem.value
if (!item) return
const path = `${currentPath.value}/${item.name}`.replace('//', '/')
deleteMutation.mutate({ path, recursive: item.type === 'directory' })
}
function showCreateModal(type: 'file' | 'directory') {
newItemType.value = type
createItemModal.value?.show()
}
function showUnzipFromUrlModal(_cf: boolean) {
// TODO: Implement unzip from URL modal
addNotification({
title: 'Not implemented',
text: 'Unzip from URL is not yet implemented',
type: 'info',
})
}
function showRenameModal(item: Kyros.Files.v0.DirectoryItem) {
selectedItem.value = item
renameItemModal.value?.show(item)
}
function showMoveModal(item: Kyros.Files.v0.DirectoryItem) {
selectedItem.value = item
moveItemModal.value?.show()
}
function showDeleteModal(item: Kyros.Files.v0.DirectoryItem) {
selectedItem.value = item
deleteItemModal.value?.show()
}
async function showBulkMoveModal() {
addNotification({
title: 'Bulk move',
text: `Moving ${selectedItems.value.size} items is not yet implemented`,
type: 'info',
})
}
async function showBulkDeleteModal() {
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) {
deleteMutation.mutate({ path, recursive: item.type === 'directory' })
}
}
deselectAll()
}
function handleSort(field: string) {
if (sortMethod.value === field) {
sortDesc.value = !sortDesc.value
} else {
sortMethod.value = field
sortDesc.value = false
}
}
function applySort(items: Kyros.Files.v0.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
}
function compareItems(a: Kyros.Files.v0.DirectoryItem, b: Kyros.Files.v0.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 ? a.modified - b.modified : b.modified - a.modified
case 'created':
return sortDesc.value ? a.created - b.created : b.created - a.created
case 'size': {
const aValue =
a.type === 'directory'
? 'count' in a && a.count !== undefined
? a.count
: 0
: 'size' in a && a.size !== undefined
? a.size
: 0
const bValue =
b.type === 'directory'
? 'count' in b && b.count !== undefined
? b.count
: 0
: 'size' in b && b.size !== undefined
? b.size
: 0
return sortDesc.value ? aValue - bValue : bValue - aValue
}
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)
})
async function handleLoadMore() {
if (isFetchingNextPage.value || !hasNextPage.value) return
await fetchNextPage()
}
function onKeydown(e: KeyboardEvent) {
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()
}
}
function editFile(item: { name: string; type: string; path: string }) {
editingFile.value = item
isEditing.value = true
router.push({ query: { ...route.query, path: currentPath.value, editing: item.path } })
}
function initializeFileEdit() {
if (!route.query.editing) return
const filePath = route.query.editing as string
editingFile.value = {
name: filePath.split('/').pop() || '',
type: 'file',
path: filePath,
}
isEditing.value = true
}
function handleEditorClose() {
isEditing.value = false
editingFile.value = null
const newQuery = { ...route.query }
delete newQuery.editing
router.replace({ query: newQuery })
}
onMounted(async () => {
if (modulesLoaded) await modulesLoaded
if (typeof window !== 'undefined') {
const { VAceEditor: Ace } = await import('vue3-ace-editor')
await Promise.all([
import('ace-builds/src-noconflict/mode-json'),
import('ace-builds/src-noconflict/mode-yaml'),
import('ace-builds/src-noconflict/mode-toml'),
import('ace-builds/src-noconflict/mode-sh'),
import('ace-builds/src-noconflict/mode-batchfile'),
import('ace-builds/src-noconflict/mode-powershell'),
import('ace-builds/src-noconflict/mode-java'),
import('ace-builds/src-noconflict/mode-javascript'),
import('ace-builds/src-noconflict/mode-typescript'),
import('ace-builds/src-noconflict/mode-python'),
import('ace-builds/src-noconflict/mode-ruby'),
import('ace-builds/src-noconflict/mode-php'),
import('ace-builds/src-noconflict/mode-html'),
import('ace-builds/src-noconflict/mode-css'),
import('ace-builds/src-noconflict/mode-c_cpp'),
import('ace-builds/src-noconflict/mode-rust'),
import('ace-builds/src-noconflict/mode-golang'),
import('ace-builds/src-noconflict/mode-markdown'),
import('ace-builds/src-noconflict/mode-properties'),
import('ace-builds/src-noconflict/mode-ini'),
import('ace-builds/src-noconflict/mode-text'),
import('../../../utils/ace-theme.ts'),
])
VAceEditor.value = Ace
}
initializeFileEdit()
document.addEventListener('keydown', onKeydown)
localQueuedOps.value = []
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
labelBarObserver?.disconnect()
})
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
const ops = computed<(QueuedOpWithState | Archon.Websocket.v0.FilesystemOperation)[]>(() => [
...localQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
...fsOps.value,
])
async function dismissOrCancelOp(opId: string, action: 'dismiss' | 'cancel') {
try {
await client.kyros.files_v0.modifyOperation(opId, action)
} catch (error) {
console.error(`Failed to ${action} operation:`, error)
}
}
watch(
() => fsOps.value,
() => {
refreshList()
},
)
watch(
labelBarSentinel,
(newSentinel) => {
// Disconnect any existing observer
if (labelBarObserver) {
labelBarObserver.disconnect()
labelBarObserver = null
}
// Create new observer when sentinel becomes available
if (newSentinel) {
labelBarObserver = new IntersectionObserver(
([entry]) => {
isLabelBarStuck.value = !entry.isIntersecting
},
{ threshold: 0 },
)
labelBarObserver.observe(newSentinel)
}
},
{ flush: 'post' },
)
watch(
() => route.query,
(newQuery, oldQuery) => {
if (newQuery.path !== oldQuery?.path) {
searchQuery.value = ''
viewFilter.value = 'all'
sortMethod.value = 'name'
sortDesc.value = false
deselectAll()
}
if (newQuery.editing && editingFile.value?.path !== newQuery.editing) {
editingFile.value = {
name: (newQuery.editing as string).split('/').pop() || '',
type: 'file',
path: newQuery.editing as string,
}
isEditing.value = true
} else if (oldQuery?.editing && !newQuery.editing) {
isEditing.value = false
editingFile.value = null
}
},
{ deep: true },
)
const breadcrumbSegments = computed(() => {
if (typeof currentPath.value === 'string') {
return currentPath.value.split('/').filter(Boolean)
}
return []
})
function navigateToSegment(index: number) {
const newPath = index === -1 ? '/' : breadcrumbSegments.value.slice(0, index + 1).join('/')
// Don't navigate if already at target path (unless editing, to close editor)
if (newPath === currentPath.value && !isEditing.value) {
return
}
router.push({ query: { ...route.query, path: newPath } })
if (isEditing.value) {
isEditing.value = false
editingFile.value = null
const newQuery = { ...route.query }
delete newQuery.editing
router.replace({ query: newQuery })
}
}
function handleDroppedFiles(files: File[]) {
if (isEditing.value) return
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
function 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()
}
async function downloadFile(item: Kyros.Files.v0.DirectoryItem) {
if (item.type === 'file') {
try {
const path = `${currentPath.value}/${item.name}`.replace('//', '/')
const fileData = await client.kyros.files_v0.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)
addNotification({
title: 'Download failed',
text: 'Could not download the file.',
type: 'error',
})
}
}
}
provide('modulesLoaded', modulesLoaded)
</script>
<style scoped>
.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%;
}
}
.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>