mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44:50 +00:00
* 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 74758fe185.
* 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>
535 lines
14 KiB
Vue
535 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import type { Archon, Kyros } from '@modrinth/api-client'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import { useVIntl } from '#ui/composables/i18n'
|
|
import {
|
|
injectModrinthClient,
|
|
injectModrinthServerContext,
|
|
injectNotificationManager,
|
|
} from '#ui/providers'
|
|
import { commonMessages } from '#ui/utils/common-messages'
|
|
|
|
import FilePageLayout from '../../../shared/files-tab/layout.vue'
|
|
import { provideFileManager } from '../../../shared/files-tab/providers/file-manager'
|
|
import type {
|
|
EditingFile,
|
|
FileItem,
|
|
FileOperation,
|
|
UploadState,
|
|
} from '../../../shared/files-tab/types'
|
|
|
|
const props = defineProps<{
|
|
showDebugInfo?: boolean
|
|
showRefreshButton?: boolean
|
|
}>()
|
|
|
|
const client = injectModrinthClient()
|
|
const serverContext = injectModrinthServerContext()
|
|
const { serverId, fsOps, fsQueuedOps, busyReasons } = serverContext
|
|
const { addNotification } = injectNotificationManager()
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const queryClient = useQueryClient()
|
|
|
|
const serverBusy = computed(() => busyReasons.value.length > 0)
|
|
const busyTooltip = computed(() =>
|
|
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
|
|
)
|
|
const nonBackupBusyReasons = computed(() =>
|
|
busyReasons.value.filter(
|
|
(r) =>
|
|
r.reason.id !== 'servers.busy.backup-creating' &&
|
|
r.reason.id !== 'servers.busy.backup-restoring',
|
|
),
|
|
)
|
|
|
|
const busyWarning = computed(() =>
|
|
nonBackupBusyReasons.value.length > 0
|
|
? formatMessage(nonBackupBusyReasons.value[0].reason)
|
|
: null,
|
|
)
|
|
|
|
// Path & navigation
|
|
const currentPath = computed(() => (typeof route.query.path === 'string' ? route.query.path : '/'))
|
|
|
|
function navigateTo(path: string) {
|
|
const { editing: _, ...query } = route.query
|
|
router.push({ query: { ...query, path } })
|
|
}
|
|
|
|
// Editing state (synced with URL)
|
|
const editingFile = ref<EditingFile | null>(null)
|
|
|
|
function startEditing(file: EditingFile) {
|
|
editingFile.value = file
|
|
router.push({ query: { ...route.query, path: currentPath.value, editing: file.path } })
|
|
}
|
|
|
|
function stopEditing() {
|
|
editingFile.value = null
|
|
const newQuery = { ...route.query }
|
|
delete newQuery.editing
|
|
router.replace({ query: newQuery })
|
|
}
|
|
|
|
// Sync editing state from URL
|
|
watch(
|
|
() => route.query,
|
|
(newQuery, oldQuery) => {
|
|
if (newQuery.editing && editingFile.value?.path !== newQuery.editing) {
|
|
editingFile.value = {
|
|
name: (newQuery.editing as string).split('/').pop() || '',
|
|
path: newQuery.editing as string,
|
|
}
|
|
} else if (oldQuery?.editing && !newQuery.editing) {
|
|
editingFile.value = null
|
|
}
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
// Initialize editing from URL on mount
|
|
function initializeFileEdit() {
|
|
if (!route.query.editing) return
|
|
const filePath = route.query.editing as string
|
|
editingFile.value = {
|
|
name: filePath.split('/').pop() || '',
|
|
path: filePath,
|
|
}
|
|
}
|
|
|
|
// Directory listing query
|
|
const {
|
|
data: directoryData,
|
|
isLoading,
|
|
error: loadError,
|
|
} = useQuery({
|
|
queryKey: computed(() => ['files', serverId, currentPath.value]),
|
|
queryFn: async () => {
|
|
return client.kyros.files_v0.listDirectory(currentPath.value, 1, 2000)
|
|
},
|
|
staleTime: 30_000,
|
|
})
|
|
|
|
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
|
|
|
|
// Prefetching
|
|
function prefetchDirectory(path: string) {
|
|
queryClient.prefetchQuery({
|
|
queryKey: ['files', serverId, path],
|
|
queryFn: async () => {
|
|
try {
|
|
return await client.kyros.files_v0.listDirectory(path, 1, 2000)
|
|
} catch {
|
|
return { items: [], total: 0, current: 1 }
|
|
}
|
|
},
|
|
staleTime: 30_000,
|
|
})
|
|
}
|
|
|
|
function prefetchFile(path: string) {
|
|
queryClient.prefetchQuery({
|
|
queryKey: ['file-content', serverId, path],
|
|
queryFn: async () => {
|
|
try {
|
|
const blob = await client.kyros.files_v0.downloadFile(path)
|
|
return await blob.text()
|
|
} catch {
|
|
return null
|
|
}
|
|
},
|
|
staleTime: 30_000,
|
|
})
|
|
}
|
|
|
|
function getQueryKey() {
|
|
return ['files', serverId, currentPath.value]
|
|
}
|
|
|
|
function refreshList() {
|
|
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
|
|
}
|
|
|
|
// Mutations
|
|
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: Kyros.Files.v0.DirectoryResponse | undefined) => {
|
|
if (!old) return old
|
|
return { ...old, items: old.items.filter((item) => item.path !== path) }
|
|
})
|
|
return { previous }
|
|
},
|
|
onError: (err: Error, _vars, context) => {
|
|
queryClient.setQueryData(getQueryKey(), context?.previous)
|
|
addNotification({
|
|
title: formatMessage(commonMessages.deleteFailedLabel),
|
|
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: Kyros.Files.v0.DirectoryResponse | undefined) => {
|
|
if (!old) return old
|
|
return {
|
|
...old,
|
|
items: old.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: formatMessage(commonMessages.renameFailedLabel),
|
|
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: Kyros.Files.v0.DirectoryResponse | undefined) => {
|
|
if (!old) return old
|
|
return { ...old, items: old.items.filter((item) => item.path !== source) }
|
|
})
|
|
return { previous }
|
|
},
|
|
onError: (err: Error, _vars, context) => {
|
|
queryClient.setQueryData(getQueryKey(), context?.previous)
|
|
addNotification({
|
|
title: formatMessage(commonMessages.moveFailedLabel),
|
|
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: Kyros.Files.v0.DirectoryResponse | undefined) => {
|
|
if (!old) return old
|
|
return { ...old, items: [newItem, ...old.items] }
|
|
})
|
|
return { previous }
|
|
},
|
|
onError: (err: Error, _vars, context) => {
|
|
queryClient.setQueryData(getQueryKey(), context?.previous)
|
|
addNotification({
|
|
title: formatMessage(commonMessages.createFailedLabel),
|
|
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] })
|
|
},
|
|
})
|
|
|
|
// Extraction
|
|
async function extractFile(path: string, override: boolean, dry: boolean) {
|
|
if (dry) {
|
|
return await client.kyros.files_v0.extractFile(path, override, true)
|
|
}
|
|
await client.kyros.files_v0.extractFile(path, override, false)
|
|
}
|
|
|
|
// File I/O
|
|
async function readFile(path: string): Promise<string> {
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
const cachedContent = queryClient.getQueryData<string>(['file-content', serverId, normalizedPath])
|
|
if (cachedContent) return cachedContent
|
|
const blob = await client.kyros.files_v0.downloadFile(normalizedPath)
|
|
return await blob.text()
|
|
}
|
|
|
|
async function readFileAsBlob(path: string): Promise<Blob> {
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
return await client.kyros.files_v0.downloadFile(normalizedPath)
|
|
}
|
|
|
|
async function writeFile(path: string, content: string): Promise<void> {
|
|
await client.kyros.files_v0.updateFile(path, content)
|
|
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
|
}
|
|
|
|
async function downloadFile(path: string, fileName: string): Promise<void> {
|
|
try {
|
|
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 = fileName
|
|
link.click()
|
|
window.URL.revokeObjectURL(link.href)
|
|
}
|
|
} catch {
|
|
addNotification({
|
|
title: formatMessage(commonMessages.downloadFailedLabel),
|
|
text: 'Could not download the file.',
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Operations tracking
|
|
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
|
const localQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
|
const dismissedOpIds = ref<Set<string>>(new Set())
|
|
|
|
const activeOps = computed<FileOperation[]>(() => [
|
|
...localQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
|
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
|
...(fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)) as FileOperation[]),
|
|
])
|
|
|
|
async function dismissOperation(opId: string, action: 'dismiss' | 'cancel') {
|
|
if (action === 'dismiss') {
|
|
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
|
|
}
|
|
try {
|
|
await client.kyros.files_v0.modifyOperation(opId, action)
|
|
} catch (error) {
|
|
if (action === 'dismiss') return
|
|
console.error(`Failed to ${action} operation:`, error)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => fsOps.value,
|
|
(newOps) => {
|
|
for (const op of newOps) {
|
|
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
|
|
setTimeout(() => {
|
|
dismissOperation(op.id, 'dismiss')
|
|
}, 3000)
|
|
}
|
|
}
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
watch(
|
|
() => fsOps.value,
|
|
() => {
|
|
refreshList()
|
|
},
|
|
)
|
|
|
|
onMounted(async () => {
|
|
initializeFileEdit()
|
|
localQueuedOps.value = []
|
|
})
|
|
|
|
// Restart
|
|
async function restartServer() {
|
|
await client.archon.servers_v0.power(serverId, 'Restart')
|
|
}
|
|
|
|
// Upload state
|
|
const uploadState = ref<UploadState>({
|
|
isUploading: false,
|
|
currentFileName: null,
|
|
currentFileProgress: 0,
|
|
uploadedBytes: 0,
|
|
totalBytes: 0,
|
|
completedFiles: 0,
|
|
totalFiles: 0,
|
|
})
|
|
|
|
let activeUploadCancel: (() => void) | null = null
|
|
|
|
async function uploadFiles(files: File[]) {
|
|
if (files.length === 0) return
|
|
|
|
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
|
uploadState.value = {
|
|
isUploading: true,
|
|
currentFileName: files[0].name,
|
|
currentFileProgress: 0,
|
|
uploadedBytes: 0,
|
|
totalBytes,
|
|
completedFiles: 0,
|
|
totalFiles: files.length,
|
|
}
|
|
|
|
let completedBytes = 0
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i]
|
|
const filePath = `${currentPath.value}/${file.name}`.replace('//', '/')
|
|
|
|
uploadState.value.currentFileName = file.name
|
|
uploadState.value.currentFileProgress = 0
|
|
|
|
try {
|
|
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
|
onProgress: ({ progress }) => {
|
|
uploadState.value.currentFileProgress = progress
|
|
uploadState.value.uploadedBytes = completedBytes + Math.round(file.size * progress)
|
|
},
|
|
})
|
|
activeUploadCancel = () => uploader.cancel()
|
|
|
|
await uploader.promise
|
|
completedBytes += file.size
|
|
uploadState.value.completedFiles = i + 1
|
|
uploadState.value.uploadedBytes = completedBytes
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === 'Upload cancelled') break
|
|
addNotification({
|
|
title: formatMessage(commonMessages.uploadFailedLabel),
|
|
text: `Failed to upload ${file.name}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
activeUploadCancel = null
|
|
refreshList()
|
|
uploadState.value = {
|
|
isUploading: false,
|
|
currentFileName: null,
|
|
currentFileProgress: 0,
|
|
uploadedBytes: 0,
|
|
totalBytes: 0,
|
|
completedFiles: 0,
|
|
totalFiles: 0,
|
|
}
|
|
}
|
|
|
|
function cancelUpload() {
|
|
activeUploadCancel?.()
|
|
}
|
|
|
|
// Provide the file manager context
|
|
provideFileManager({
|
|
items,
|
|
loading: computed(() => isLoading.value),
|
|
error: computed(() => loadError.value ?? null),
|
|
currentPath,
|
|
navigateTo,
|
|
editingFile,
|
|
startEditing,
|
|
stopEditing,
|
|
createItem: async (name, type) => {
|
|
const path = `${currentPath.value}/${name}`.replace('//', '/')
|
|
await createMutation.mutateAsync({ path, type })
|
|
},
|
|
renameItem: async (path, newName) => {
|
|
await renameMutation.mutateAsync({ path, newName })
|
|
},
|
|
moveItem: async (source, destination) => {
|
|
await moveMutation.mutateAsync({ source, destination })
|
|
},
|
|
deleteItem: async (path, recursive) => {
|
|
await deleteMutation.mutateAsync({ path, recursive })
|
|
},
|
|
readFile,
|
|
readFileAsBlob,
|
|
writeFile,
|
|
downloadFile,
|
|
uploadFiles,
|
|
cancelUpload,
|
|
uploadState,
|
|
refresh: refreshList,
|
|
isBusy: serverBusy,
|
|
busyTooltip,
|
|
busyWarning,
|
|
extractFile,
|
|
activeOperations: activeOps,
|
|
dismissOperation,
|
|
prefetchDirectory,
|
|
prefetchFile,
|
|
showInstallFromUrl: true,
|
|
canRestart: true,
|
|
restartServer,
|
|
canShareToMclogs: true,
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<FilePageLayout
|
|
:show-debug-info="props.showDebugInfo"
|
|
:show-refresh-button="props.showRefreshButton"
|
|
/>
|
|
</template>
|