feat:移除了弹窗,服务器添加sls
This commit is contained in:
1127
apps/app-frontend/src/pages/instance/FileStudio.vue
Normal file
1127
apps/app-frontend/src/pages/instance/FileStudio.vue
Normal file
File diff suppressed because it is too large
Load Diff
360
apps/app-frontend/src/pages/instance/Files.vue
Normal file
360
apps/app-frontend/src/pages/instance/Files.vue
Normal file
@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, FileArchiveIcon } from '@modrinth/assets'
|
||||
import type { EditingFile, FileContextMenuOption, FileItem } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilePageLayout,
|
||||
injectNotificationManager,
|
||||
provideFileManager,
|
||||
ReadyTransition,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import {
|
||||
mkdir,
|
||||
readDir,
|
||||
readFile as readFileBytes,
|
||||
readTextFile,
|
||||
remove,
|
||||
rename,
|
||||
stat,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/plugin-fs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: unknown
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
isServerInstance: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('Files')
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
saveAs: {
|
||||
id: 'instance.files.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
addingFiles: {
|
||||
id: 'instance.files.adding-files',
|
||||
defaultMessage: 'Adding files ({completed}/{total})',
|
||||
},
|
||||
openInSchematicWorkshop: {
|
||||
id: 'instance.files.open-in-schematic-workshop',
|
||||
defaultMessage: 'Open in schematic workshop',
|
||||
},
|
||||
openStudio: {
|
||||
id: 'instance.files.open-studio',
|
||||
defaultMessage: 'Open Studio',
|
||||
},
|
||||
})
|
||||
|
||||
const instanceRoot = ref('')
|
||||
const items = ref<FileItem[]>([])
|
||||
/** True until the first directory read for the current instance path finishes (initial load only). */
|
||||
const firstPaintPending = ref(true)
|
||||
const loading = ref(true)
|
||||
const error = ref<Error | null>(null)
|
||||
const currentPath = ref('')
|
||||
const editingFile = ref<EditingFile | null>(null)
|
||||
|
||||
debug('setup: start, instance.id =', props.instance.id)
|
||||
|
||||
instanceRoot.value = await get_full_path(props.instance.id)
|
||||
debug('setup: instanceRoot =', instanceRoot.value)
|
||||
await refresh()
|
||||
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
|
||||
|
||||
async function resolvePath(relativePath: string): Promise<string> {
|
||||
return relativePath ? join(instanceRoot.value, ...relativePath.split('/')) : instanceRoot.value
|
||||
}
|
||||
|
||||
async function listDirectory(dirPath: string): Promise<FileItem[]> {
|
||||
const absPath = await resolvePath(dirPath)
|
||||
debug('listDirectory: dirPath =', dirPath, 'absPath =', absPath)
|
||||
const entries = await readDir(absPath)
|
||||
debug('listDirectory: got', entries.length, 'entries')
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryAbsPath = await join(absPath, entry.name)
|
||||
let metadata
|
||||
try {
|
||||
metadata = await stat(entryAbsPath)
|
||||
} catch {
|
||||
debug('listDirectory: stat failed for', entry.name, '- skipping')
|
||||
return null
|
||||
}
|
||||
const item: FileItem = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory ? 'directory' : 'file',
|
||||
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : 0,
|
||||
created: metadata.birthtime ? Math.floor(metadata.birthtime.getTime() / 1000) : 0,
|
||||
}
|
||||
if (!entry.isDirectory) {
|
||||
item.size = metadata.size
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
const children = await readDir(entryAbsPath)
|
||||
item.count = children.length
|
||||
} catch {
|
||||
item.count = 0
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
return results.filter((item): item is FileItem => item !== null)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await listDirectory(currentPath.value)
|
||||
debug('refresh: success, items =', items.value.length)
|
||||
} catch (e) {
|
||||
debug('refresh: error =', e)
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstPaintPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
debug('navigateTo:', path)
|
||||
currentPath.value = path.startsWith('/') ? path.slice(1) : path
|
||||
refresh()
|
||||
}
|
||||
|
||||
function startEditing(file: EditingFile) {
|
||||
editingFile.value = file
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingFile.value = null
|
||||
}
|
||||
|
||||
async function handleCreateItem(name: string, type: 'file' | 'directory') {
|
||||
const targetPath = currentPath.value ? `${currentPath.value}/${name}` : name
|
||||
const absPath = await resolvePath(targetPath)
|
||||
try {
|
||||
if (type === 'directory') {
|
||||
await mkdir(absPath)
|
||||
} else {
|
||||
await writeTextFile(absPath, '')
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.createFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameItem(path: string, newName: string) {
|
||||
const oldAbs = await resolvePath(path)
|
||||
const parentDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''
|
||||
const newPath = parentDir ? `${parentDir}/${newName}` : newName
|
||||
const newAbs = await resolvePath(newPath)
|
||||
try {
|
||||
await rename(oldAbs, newAbs)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.renameFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveItem(source: string, destination: string) {
|
||||
try {
|
||||
await rename(await resolvePath(source), await resolvePath(destination))
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.moveFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(path: string, recursive: boolean) {
|
||||
try {
|
||||
await remove(await resolvePath(path), { recursive })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.deleteFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReadFile(path: string): Promise<string> {
|
||||
return await readTextFile(await resolvePath(path))
|
||||
}
|
||||
|
||||
async function handleReadFileAsBlob(path: string): Promise<Blob> {
|
||||
const bytes = await readFileBytes(await resolvePath(path))
|
||||
return new Blob([bytes])
|
||||
}
|
||||
|
||||
async function handleWriteFile(path: string, content: string) {
|
||||
await writeTextFile(await resolvePath(path), content)
|
||||
}
|
||||
|
||||
async function handleDownloadFile(path: string, _fileName: string) {
|
||||
await invoke('plugin:files|file_save_as', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: path,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
|
||||
try {
|
||||
return await invoke('plugin:files|file_extract_zip', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: path,
|
||||
overrideConflicts: override,
|
||||
dryRun: dry,
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getAdditionalMenuOptions(
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'>,
|
||||
): FileContextMenuOption[] {
|
||||
if (item.type !== 'file' || !/\.(litematic|schem)$/i.test(item.name)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'open-in-schematic-workshop',
|
||||
label: formatMessage(messages.openInSchematicWorkshop),
|
||||
icon: FileArchiveIcon,
|
||||
action: () => {
|
||||
void router.push({
|
||||
name: 'Schematic workshop',
|
||||
query: { instance: props.instance.id, path: item.path },
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
debug('setup: registering instance_listener')
|
||||
const unlistenInstances = await instance_listener(
|
||||
async (event: { event: string; instance_id: string }) => {
|
||||
debug('instance_listener: event =', event.event, 'path =', event.instance_id)
|
||||
if (event.instance_id === props.instance.id && event.event === 'synced') {
|
||||
debug('instance_listener: synced event matched, calling refresh')
|
||||
await refresh()
|
||||
}
|
||||
},
|
||||
)
|
||||
debug('setup: instance_listener registered')
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenInstances()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance.id,
|
||||
async () => {
|
||||
debug('watch instance.id: changed to', props.instance.id)
|
||||
firstPaintPending.value = true
|
||||
instanceRoot.value = await get_full_path(props.instance.id)
|
||||
currentPath.value = ''
|
||||
await refresh()
|
||||
},
|
||||
)
|
||||
|
||||
provideFileManager({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
currentPath,
|
||||
navigateTo,
|
||||
editingFile,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
createItem: handleCreateItem,
|
||||
renameItem: handleRenameItem,
|
||||
moveItem: handleMoveItem,
|
||||
deleteItem: handleDeleteItem,
|
||||
readFile: handleReadFile,
|
||||
readFileAsBlob: handleReadFileAsBlob,
|
||||
writeFile: handleWriteFile,
|
||||
downloadFile: handleDownloadFile,
|
||||
extractFile: handleExtractFile,
|
||||
refresh,
|
||||
basePath: instanceRoot,
|
||||
openInFolder: (path: string) => highlightInFolder(path),
|
||||
getAdditionalMenuOptions,
|
||||
downloadButtonLabel: formatMessage(messages.saveAs),
|
||||
uploadingLabel: (completed: number, total: number) =>
|
||||
formatMessage(messages.addingFiles, { completed, total }),
|
||||
symlinkTarget: computed(() => props.instance.symlink_target),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReadyTransition :pending="firstPaintPending">
|
||||
<div class="flex flex-col gap-4">
|
||||
<FilePageLayout :show-refresh-button="true">
|
||||
<template #before-refresh>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
class="!h-10"
|
||||
@click="router.push({ name: 'FileStudio', params: { id: instance.id } })"
|
||||
>
|
||||
<CodeIcon class="size-5" />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ formatMessage(messages.openStudio) }}
|
||||
<span
|
||||
class="rounded bg-orange px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none text-contrast"
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</FilePageLayout>
|
||||
</div>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
1160
apps/app-frontend/src/pages/instance/Index.vue
Normal file
1160
apps/app-frontend/src/pages/instance/Index.vue
Normal file
File diff suppressed because it is too large
Load Diff
248
apps/app-frontend/src/pages/instance/Logs.vue
Normal file
248
apps/app-frontend/src/pages/instance/Logs.vue
Normal file
@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<ConsolePageLayout />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ConsolePageLayout,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
provideConsoleManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useCrashAnalysis } from '@/composables/useCrashAnalysis'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { log_listener, process_listener } from '@/helpers/events.js'
|
||||
import {
|
||||
delete_logs_by_filename,
|
||||
export_crash_context,
|
||||
get_output_by_filename,
|
||||
} from '@/helpers/logs.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
liveLog: { id: 'instance.logs.source.live', defaultMessage: 'Live Log' },
|
||||
unknownLog: { id: 'instance.logs.source.unknown', defaultMessage: 'Unknown' },
|
||||
logName: { id: 'instance.logs.source.numbered', defaultMessage: 'Log {index}' },
|
||||
cannotDeleteLatest: {
|
||||
id: 'instance.logs.delete.latest-running',
|
||||
defaultMessage: 'Cannot delete latest.log while the instance is running',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const instanceId = computed(() => route.params.id)
|
||||
const {
|
||||
analysis: localCrashAnalysis,
|
||||
loading: crashAnalysisLoading,
|
||||
refresh: refreshCrashAnalysis,
|
||||
clear: clearCrashAnalysis,
|
||||
} = useCrashAnalysis(instanceId.value)
|
||||
const {
|
||||
liveConsole,
|
||||
historicalConsole,
|
||||
hydrate,
|
||||
getHistoricalLogs,
|
||||
getHistoricalContent,
|
||||
invalidate,
|
||||
clearLive,
|
||||
} = useInstanceConsole(instanceId.value)
|
||||
|
||||
await hydrate()
|
||||
|
||||
function buildLogList(rawLogs) {
|
||||
return [
|
||||
{ name: formatMessage(messages.liveLog), live: true },
|
||||
...rawLogs
|
||||
.filter(
|
||||
(log) =>
|
||||
log.filename !== 'latest_stdout.log' &&
|
||||
log.filename !== 'latest_stdout' &&
|
||||
log.filename !== 'launcher_log.txt' &&
|
||||
(log.output == null || log.output !== '') &&
|
||||
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
|
||||
)
|
||||
.map((log) => ({
|
||||
...log,
|
||||
name: log.filename || formatMessage(messages.unknownLog),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const logs = ref(buildLogList([]))
|
||||
|
||||
void getHistoricalLogs()
|
||||
.then((allLogs) => {
|
||||
logs.value = buildLogList(allLogs)
|
||||
})
|
||||
.catch(handleError)
|
||||
|
||||
const selectedLogIndex = ref(0)
|
||||
const isLive = computed(() => selectedLogIndex.value === 0)
|
||||
|
||||
const filteredLogs = computed(() =>
|
||||
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
|
||||
)
|
||||
|
||||
const logSources = computed(() =>
|
||||
filteredLogs.value.map((l, i) => ({
|
||||
id: String(i),
|
||||
name: l?.name ?? formatMessage(messages.logName, { index: i }),
|
||||
live: l?.live ?? false,
|
||||
})),
|
||||
)
|
||||
|
||||
const activeConsole = computed(() => (isLive.value ? liveConsole : historicalConsole))
|
||||
|
||||
const logLines = shallowRef(activeConsole.value.output.value)
|
||||
watchEffect(() => {
|
||||
logLines.value = activeConsole.value.output.value
|
||||
triggerRef(logLines)
|
||||
})
|
||||
|
||||
async function analyseForCrash() {
|
||||
await refreshCrashAnalysis().catch((error) => {
|
||||
handleError(error)
|
||||
})
|
||||
}
|
||||
|
||||
async function exportCrashContext() {
|
||||
await export_crash_context(props.instance.id, props.instance.name).catch(handleError)
|
||||
}
|
||||
|
||||
const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
|
||||
|
||||
const deleteDisabled = computed(() => {
|
||||
const log = selectedLog.value
|
||||
if (!log || log.live) return true
|
||||
return log.filename === 'latest.log' && props.playing
|
||||
})
|
||||
|
||||
async function deleteSelectedLog() {
|
||||
const log = selectedLog.value
|
||||
if (!log || log.live) return
|
||||
await delete_logs_by_filename(props.instance.id, log.log_type, log.filename)
|
||||
invalidate()
|
||||
const freshLogs = await getHistoricalLogs()
|
||||
logs.value = buildLogList(freshLogs)
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
|
||||
provideConsoleManager({
|
||||
logLines,
|
||||
logSources,
|
||||
activeLogSourceIndex: selectedLogIndex,
|
||||
showCommandInput: false,
|
||||
loading: ref(false),
|
||||
onClear: () => {
|
||||
if (!isLive.value) return
|
||||
void clearLive()
|
||||
},
|
||||
onDelete: deleteSelectedLog,
|
||||
deleteDisabled,
|
||||
deleteDisabledTooltip: computed(() => formatMessage(messages.cannotDeleteLatest)),
|
||||
shareDisabled: computed(() => props.offline),
|
||||
emptyStateType: 'instance',
|
||||
localCrashAnalysis,
|
||||
crashAnalysisLoading,
|
||||
onExportCrashContext: exportCrashContext,
|
||||
})
|
||||
|
||||
watch(selectedLogIndex, async (newIndex) => {
|
||||
if (newIndex === 0) return
|
||||
const log = filteredLogs.value[newIndex]
|
||||
if (!log) return
|
||||
|
||||
const cached = getHistoricalContent(log.filename)
|
||||
if (cached) {
|
||||
historicalConsole.clear()
|
||||
await historicalConsole.addLegacyLog(cached)
|
||||
return
|
||||
}
|
||||
|
||||
const output = await get_output_by_filename(props.instance.id, log.log_type, log.filename).catch(
|
||||
handleError,
|
||||
)
|
||||
if (output) {
|
||||
historicalConsole.clear()
|
||||
await historicalConsole.addLegacyLog(output)
|
||||
}
|
||||
})
|
||||
|
||||
selectedLogIndex.value = 0
|
||||
|
||||
if (!props.playing) {
|
||||
void analyseForCrash()
|
||||
}
|
||||
|
||||
const unlistenLog = await log_listener((payload) => {
|
||||
if (payload.instance_id !== instanceId.value) return
|
||||
|
||||
if (payload.type === 'log4j') {
|
||||
liveConsole.addLog4jEvent(payload)
|
||||
} else if (payload.type === 'legacy') {
|
||||
void liveConsole.addLegacyLog(payload.message)
|
||||
}
|
||||
})
|
||||
|
||||
const unlistenProcesses = await process_listener(async (e) => {
|
||||
if (e.instance_id !== instanceId.value) return
|
||||
if (e.event === 'launched') {
|
||||
liveConsole.clear()
|
||||
clearCrashAnalysis()
|
||||
invalidate()
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
if (e.event === 'finished') {
|
||||
invalidate()
|
||||
const freshLogs = await getHistoricalLogs()
|
||||
logs.value = buildLogList(freshLogs)
|
||||
void analyseForCrash()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenLog()
|
||||
unlistenProcesses()
|
||||
})
|
||||
</script>
|
||||
3248
apps/app-frontend/src/pages/instance/Mods.vue
Normal file
3248
apps/app-frontend/src/pages/instance/Mods.vue
Normal file
File diff suppressed because it is too large
Load Diff
13
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
13
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>{{ instance.name }} overview</template>
|
||||
<script setup lang="ts">
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
}>()
|
||||
</script>
|
||||
772
apps/app-frontend/src/pages/instance/Screenshots.vue
Normal file
772
apps/app-frontend/src/pages/instance/Screenshots.vue
Normal file
@ -0,0 +1,772 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
ContractIcon,
|
||||
DownloadIcon,
|
||||
ExpandIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
LeftArrowIcon,
|
||||
RefreshCwIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
ReadyTransition,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import { exists, mkdir, readDir, readFile, remove, stat } from '@tauri-apps/plugin-fs'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { highlightInFolder, openPath } from '@/helpers/utils'
|
||||
|
||||
interface Screenshot {
|
||||
name: string
|
||||
path: string
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
thumbnailFailed?: boolean
|
||||
objectUrl?: string
|
||||
modified: Date
|
||||
size: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const formatDate = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'app.instance.screenshots.search-placeholder',
|
||||
defaultMessage: 'Search {count} screenshots...',
|
||||
},
|
||||
noScreenshots: {
|
||||
id: 'app.instance.screenshots.empty-title',
|
||||
defaultMessage: 'No screenshots yet',
|
||||
},
|
||||
noScreenshotsDescription: {
|
||||
id: 'app.instance.screenshots.empty-description',
|
||||
defaultMessage: 'Screenshots taken in Minecraft will appear here automatically.',
|
||||
},
|
||||
noSearchResults: {
|
||||
id: 'app.instance.screenshots.no-search-results',
|
||||
defaultMessage: 'No screenshots match your search.',
|
||||
},
|
||||
viewScreenshot: {
|
||||
id: 'app.instance.screenshots.view',
|
||||
defaultMessage: 'View screenshot',
|
||||
},
|
||||
copyScreenshot: {
|
||||
id: 'app.instance.screenshots.copy',
|
||||
defaultMessage: 'Copy image',
|
||||
},
|
||||
copiedScreenshot: {
|
||||
id: 'app.instance.screenshots.copied',
|
||||
defaultMessage: 'Screenshot copied',
|
||||
},
|
||||
copyFailed: {
|
||||
id: 'app.instance.screenshots.copy-failed',
|
||||
defaultMessage: 'Could not copy screenshot',
|
||||
},
|
||||
saveAs: {
|
||||
id: 'app.instance.screenshots.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
deleteScreenshot: {
|
||||
id: 'app.instance.screenshots.delete',
|
||||
defaultMessage: 'Delete screenshot',
|
||||
},
|
||||
deleteDescription: {
|
||||
id: 'app.instance.screenshots.delete-description',
|
||||
defaultMessage: 'Are you sure you want to permanently delete {name}?',
|
||||
},
|
||||
openScreenshotsFolder: {
|
||||
id: 'app.instance.screenshots.open-folder',
|
||||
defaultMessage: 'Open screenshots folder',
|
||||
},
|
||||
loadingFailed: {
|
||||
id: 'app.instance.screenshots.loading-failed',
|
||||
defaultMessage: 'Could not load screenshots',
|
||||
},
|
||||
deleteFailed: {
|
||||
id: 'app.instance.screenshots.delete-failed',
|
||||
defaultMessage: 'Could not delete screenshot',
|
||||
},
|
||||
zoomIn: {
|
||||
id: 'app.instance.screenshots.zoom-in',
|
||||
defaultMessage: 'View at full size',
|
||||
},
|
||||
zoomOut: {
|
||||
id: 'app.instance.screenshots.zoom-out',
|
||||
defaultMessage: 'Fit to window',
|
||||
},
|
||||
actionFailed: {
|
||||
id: 'app.instance.screenshots.action-failed',
|
||||
defaultMessage: 'Screenshot action failed',
|
||||
},
|
||||
})
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp'])
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
}
|
||||
const THUMBNAIL_MAX_DIMENSION = 1024
|
||||
const THUMBNAIL_CONCURRENCY = 2
|
||||
|
||||
const instanceRoot = ref('')
|
||||
const screenshots = ref<Screenshot[]>([])
|
||||
const loading = ref(true)
|
||||
const firstPaintPending = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const selectedScreenshot = ref<Screenshot | null>(null)
|
||||
const pendingDeletion = ref<Screenshot | null>(null)
|
||||
const zoomedIn = ref(false)
|
||||
const viewerModal = ref<InstanceType<typeof NewModal>>()
|
||||
const deleteModal = ref<InstanceType<typeof NewModal>>()
|
||||
const screenshotContextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
|
||||
const screenshotContextMenuOptions = [
|
||||
{ name: 'view_screenshot' },
|
||||
{ name: 'copy_screenshot' },
|
||||
{ name: 'save_screenshot' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'open_screenshot_folder' },
|
||||
{ name: 'copy_screenshot_filename' },
|
||||
{ name: 'copy_screenshot_path' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_screenshot', color: 'danger' },
|
||||
]
|
||||
|
||||
const screenshotsPath = ref('')
|
||||
const filteredScreenshots = computed(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase()
|
||||
if (!query) return screenshots.value
|
||||
return screenshots.value.filter((screenshot) =>
|
||||
screenshot.name.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
|
||||
function extensionOf(fileName: string): string {
|
||||
return fileName.split('.').pop()?.toLocaleLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function showError(title: string, error: unknown) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title,
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
function revokePreviewUrls(items = screenshots.value) {
|
||||
for (const screenshot of items) {
|
||||
if (screenshot.objectUrl) {
|
||||
URL.revokeObjectURL(screenshot.objectUrl)
|
||||
screenshot.objectUrl = undefined
|
||||
}
|
||||
if (screenshot.thumbnailUrl) {
|
||||
URL.revokeObjectURL(screenshot.thumbnailUrl)
|
||||
screenshot.thumbnailUrl = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let thumbnailGeneration = 0
|
||||
|
||||
async function loadScreenshotThumbnail(screenshot: Screenshot, generation: number) {
|
||||
if (screenshot.thumbnailUrl || screenshot.thumbnailFailed) return
|
||||
|
||||
try {
|
||||
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: `screenshots/${screenshot.name}`,
|
||||
maxDimension: THUMBNAIL_MAX_DIMENSION,
|
||||
})
|
||||
if (generation !== thumbnailGeneration) return
|
||||
screenshot.thumbnailUrl = URL.createObjectURL(new Blob([bytes]))
|
||||
} catch {
|
||||
screenshot.thumbnailFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnails(items: Screenshot[]) {
|
||||
const generation = thumbnailGeneration
|
||||
let nextIndex = 0
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < items.length) {
|
||||
await loadScreenshotThumbnail(items[nextIndex++], generation)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: THUMBNAIL_CONCURRENCY }, worker))
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(screenshot: Screenshot) {
|
||||
if (screenshot.objectUrl) return
|
||||
|
||||
try {
|
||||
const bytes = await readFile(screenshot.path)
|
||||
const extension = extensionOf(screenshot.name)
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([bytes], { type: MIME_TYPES[extension] ?? 'image/png' }),
|
||||
)
|
||||
screenshot.objectUrl = objectUrl
|
||||
screenshot.url = objectUrl
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.loadingFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
thumbnailGeneration += 1
|
||||
try {
|
||||
if (!(await exists(screenshotsPath.value))) {
|
||||
revokePreviewUrls()
|
||||
screenshots.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await readDir(screenshotsPath.value)
|
||||
const nextScreenshots = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.isDirectory && IMAGE_EXTENSIONS.has(extensionOf(entry.name)))
|
||||
.map(async (entry): Promise<Screenshot | null> => {
|
||||
const path = await join(screenshotsPath.value, entry.name)
|
||||
try {
|
||||
const metadata = await stat(path)
|
||||
return {
|
||||
name: entry.name,
|
||||
path,
|
||||
url: convertFileSrc(path),
|
||||
modified: metadata.mtime ?? new Date(0),
|
||||
size: metadata.size,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
revokePreviewUrls()
|
||||
screenshots.value = nextScreenshots
|
||||
.filter((screenshot): screenshot is Screenshot => screenshot !== null)
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
||||
void generateThumbnails(screenshots.value)
|
||||
} catch (error) {
|
||||
revokePreviewUrls()
|
||||
screenshots.value = []
|
||||
showError(formatMessage(messages.loadingFailed), error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstPaintPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewScreenshot(screenshot: Screenshot) {
|
||||
selectedScreenshot.value = screenshot
|
||||
zoomedIn.value = false
|
||||
viewerModal.value?.show()
|
||||
}
|
||||
|
||||
function changeScreenshot(offset: number) {
|
||||
if (!selectedScreenshot.value || screenshots.value.length < 2) return
|
||||
const currentIndex = screenshots.value.findIndex(
|
||||
(screenshot) => screenshot.path === selectedScreenshot.value?.path,
|
||||
)
|
||||
const nextIndex = (currentIndex + offset + screenshots.value.length) % screenshots.value.length
|
||||
selectedScreenshot.value = screenshots.value[nextIndex]
|
||||
zoomedIn.value = false
|
||||
}
|
||||
|
||||
async function imageToPng(blob: Blob): Promise<Blob> {
|
||||
if (blob.type === 'image/png') return blob
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = objectUrl
|
||||
await image.decode()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
canvas.getContext('2d')?.drawImage(image, 0, 0)
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error('Image conversion failed'))),
|
||||
'image/png',
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyScreenshot(screenshot: Screenshot) {
|
||||
try {
|
||||
const bytes = await readFile(screenshot.path)
|
||||
const extension = extensionOf(screenshot.name)
|
||||
const blob = new Blob([bytes], { type: MIME_TYPES[extension] ?? 'image/png' })
|
||||
const png = await imageToPng(blob)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.copiedScreenshot),
|
||||
})
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.copyFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScreenshot(screenshot: Screenshot) {
|
||||
try {
|
||||
await invoke('plugin:files|file_save_as', {
|
||||
instanceId: props.instance.id,
|
||||
filePath: `screenshots/${screenshot.name}`,
|
||||
})
|
||||
} catch (error) {
|
||||
showError(formatMessage(commonMessages.downloadFailedLabel), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openScreenshotsFolder() {
|
||||
try {
|
||||
await mkdir(screenshotsPath.value, { recursive: true })
|
||||
await openPath(screenshotsPath.value)
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.loadingFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function showScreenshotInFolder(screenshot: Screenshot) {
|
||||
try {
|
||||
await highlightInFolder(screenshot.path)
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.actionFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyScreenshotText(value: string, successTitle: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: successTitle,
|
||||
})
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.copyFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
function showScreenshotContextMenu(event: MouseEvent, screenshot: Screenshot) {
|
||||
screenshotContextMenu.value?.showMenu(event, screenshot, screenshotContextMenuOptions)
|
||||
}
|
||||
|
||||
async function handleScreenshotContextMenu({ item, option }: { item: Screenshot; option: string }) {
|
||||
switch (option) {
|
||||
case 'view_screenshot':
|
||||
viewScreenshot(item)
|
||||
break
|
||||
case 'copy_screenshot':
|
||||
await copyScreenshot(item)
|
||||
break
|
||||
case 'save_screenshot':
|
||||
await saveScreenshot(item)
|
||||
break
|
||||
case 'open_screenshot_folder':
|
||||
await showScreenshotInFolder(item)
|
||||
break
|
||||
case 'copy_screenshot_filename':
|
||||
await copyScreenshotText(item.name, formatMessage(commonMessages.copiedFilenameLabel))
|
||||
break
|
||||
case 'copy_screenshot_path':
|
||||
await copyScreenshotText(item.path, formatMessage(commonMessages.copiedPathLabel))
|
||||
break
|
||||
case 'delete_screenshot':
|
||||
promptDelete(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function promptDelete(screenshot: Screenshot) {
|
||||
pendingDeletion.value = screenshot
|
||||
deleteModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const screenshot = pendingDeletion.value
|
||||
if (!screenshot) return
|
||||
|
||||
const deletedIndex = screenshots.value.findIndex((item) => item.path === screenshot.path)
|
||||
try {
|
||||
await remove(screenshot.path)
|
||||
deleteModal.value?.hide()
|
||||
pendingDeletion.value = null
|
||||
await refresh()
|
||||
|
||||
if (selectedScreenshot.value?.path === screenshot.path) {
|
||||
if (screenshots.value.length === 0) {
|
||||
viewerModal.value?.hide()
|
||||
selectedScreenshot.value = null
|
||||
} else {
|
||||
selectedScreenshot.value =
|
||||
screenshots.value[Math.min(deletedIndex, screenshots.value.length - 1)]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(formatMessage(messages.deleteFailed), error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!selectedScreenshot.value) return
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
changeScreenshot(-1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
changeScreenshot(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize(instanceId: string) {
|
||||
firstPaintPending.value = true
|
||||
instanceRoot.value = await get_full_path(instanceId)
|
||||
screenshotsPath.value = await join(instanceRoot.value, 'screenshots')
|
||||
searchQuery.value = ''
|
||||
selectedScreenshot.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('focus', refresh)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('focus', refresh)
|
||||
thumbnailGeneration += 1
|
||||
revokePreviewUrls()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance.id,
|
||||
(instanceId) => initialize(instanceId),
|
||||
)
|
||||
|
||||
await initialize(props.instance.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReadyTransition :pending="firstPaintPending">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div v-if="screenshots.length > 0" class="relative min-w-64 flex-1 sm:max-w-md">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-secondary"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="h-10 w-full rounded-xl border border-solid border-surface-5 bg-surface-2 pl-10 pr-3 text-primary outline-none transition-colors focus:border-brand"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder, { count: screenshots.length })"
|
||||
/>
|
||||
</div>
|
||||
<div v-else />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="openScreenshotsFolder">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.openScreenshotsFolder) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.refreshButton)"
|
||||
:disabled="loading"
|
||||
:aria-label="formatMessage(commonMessages.refreshButton)"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCwIcon :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredScreenshots.length > 0"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-4"
|
||||
>
|
||||
<article
|
||||
v-for="screenshot in filteredScreenshots"
|
||||
:key="screenshot.path"
|
||||
class="group overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-2 transition-colors hover:border-brand"
|
||||
@contextmenu.prevent.stop="(event) => showScreenshotContextMenu(event, screenshot)"
|
||||
>
|
||||
<button
|
||||
class="relative block aspect-video w-full cursor-zoom-in overflow-hidden border-0 bg-surface-1 p-0"
|
||||
:aria-label="formatMessage(messages.viewScreenshot)"
|
||||
@click="viewScreenshot(screenshot)"
|
||||
>
|
||||
<img
|
||||
v-if="screenshot.thumbnailUrl || screenshot.thumbnailFailed"
|
||||
:src="screenshot.thumbnailUrl ?? screenshot.url"
|
||||
:alt="screenshot.name"
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
@error="loadScreenshotPreview(screenshot)"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-semibold text-contrast" :title="screenshot.name">
|
||||
{{ screenshot.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-secondary">
|
||||
{{ formatDate(screenshot.modified) }} · {{ formatFileSize(screenshot.size) }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyScreenshot)"
|
||||
:aria-label="formatMessage(messages.copyScreenshot)"
|
||||
@click="copyScreenshot(screenshot)"
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.openInFolderButton)"
|
||||
:aria-label="formatMessage(commonMessages.openInFolderButton)"
|
||||
@click="showScreenshotInFolder(screenshot)"
|
||||
>
|
||||
<FolderOpenIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent" color="red" color-fill="text">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteScreenshot)"
|
||||
:aria-label="formatMessage(messages.deleteScreenshot)"
|
||||
@click="promptDelete(screenshot)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="screenshots.length > 0"
|
||||
class="rounded-2xl border border-solid border-surface-5 bg-surface-2 p-8 text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noSearchResults) }}
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
type="no-images"
|
||||
:heading="formatMessage(messages.noScreenshots)"
|
||||
:description="formatMessage(messages.noScreenshotsDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<button @click="openScreenshotsFolder">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.openScreenshotsFolder) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</ReadyTransition>
|
||||
|
||||
<NewModal
|
||||
ref="viewerModal"
|
||||
:max-width="'92rem'"
|
||||
:width="'calc(100vw - 4rem)'"
|
||||
:no-padding="true"
|
||||
:header="selectedScreenshot?.name"
|
||||
:on-hide="() => (selectedScreenshot = null)"
|
||||
>
|
||||
<div
|
||||
v-if="selectedScreenshot"
|
||||
class="relative flex w-full min-h-64 max-h-[calc(100vh-13rem)] items-center justify-center overflow-hidden bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="min-w-0 flex max-h-[calc(100vh-13rem)] w-full items-center justify-center overflow-auto p-4"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
zoomedIn
|
||||
? selectedScreenshot.url
|
||||
: (selectedScreenshot.thumbnailUrl ?? selectedScreenshot.url)
|
||||
"
|
||||
:alt="selectedScreenshot.name"
|
||||
:class="
|
||||
zoomedIn
|
||||
? 'max-w-none cursor-zoom-out'
|
||||
: 'max-h-[calc(100vh-15rem)] max-w-full cursor-zoom-in'
|
||||
"
|
||||
@click="zoomedIn = !zoomedIn"
|
||||
@error="loadScreenshotPreview(selectedScreenshot)"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => showScreenshotContextMenu(event, selectedScreenshot)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled v-if="screenshots.length > 1" circular>
|
||||
<button
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2"
|
||||
:aria-label="formatMessage(commonMessages.backButton)"
|
||||
@click="changeScreenshot(-1)"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="screenshots.length > 1" circular>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
:aria-label="formatMessage(commonMessages.nextButton)"
|
||||
@click="changeScreenshot(1)"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div v-if="selectedScreenshot" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-secondary">
|
||||
{{ formatDate(selectedScreenshot.modified) }} ·
|
||||
{{ formatFileSize(selectedScreenshot.size) }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="zoomedIn = !zoomedIn">
|
||||
<ContractIcon v-if="zoomedIn" />
|
||||
<ExpandIcon v-else />
|
||||
{{ formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showScreenshotInFolder(selectedScreenshot)">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="saveScreenshot(selectedScreenshot)">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.saveAs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="copyScreenshot(selectedScreenshot)">
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(messages.copyScreenshot) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" color-fill="text">
|
||||
<button @click="promptDelete(selectedScreenshot)">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<ContextMenu ref="screenshotContextMenu" @option-clicked="handleScreenshotContextMenu">
|
||||
<template #view_screenshot>
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewScreenshot) }}
|
||||
</template>
|
||||
<template #copy_screenshot>
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(messages.copyScreenshot) }}
|
||||
</template>
|
||||
<template #save_screenshot>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.saveAs) }}
|
||||
</template>
|
||||
<template #open_screenshot_folder>
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</template>
|
||||
<template #copy_screenshot_filename>
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}
|
||||
</template>
|
||||
<template #copy_screenshot_path>
|
||||
<ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}
|
||||
</template>
|
||||
<template #delete_screenshot>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteScreenshot) }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<NewModal
|
||||
ref="deleteModal"
|
||||
fade="danger"
|
||||
:header="formatMessage(messages.deleteScreenshot)"
|
||||
:on-hide="() => (pendingDeletion = null)"
|
||||
max-width="32rem"
|
||||
>
|
||||
<p v-if="pendingDeletion" class="m-0 text-primary">
|
||||
{{ formatMessage(messages.deleteDescription, { name: pendingDeletion.name }) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="deleteModal?.hide()">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirmDelete">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
775
apps/app-frontend/src/pages/instance/WorldEditor.vue
Normal file
775
apps/app-frontend/src/pages/instance/WorldEditor.vue
Normal file
@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<ModalWrapper ref="unsavedModal">
|
||||
<template #title>
|
||||
<span class="font-extrabold text-lg text-contrast">
|
||||
{{ formatMessage(messages.unsavedTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="w-[400px] max-w-full">
|
||||
<p class="m-0">{{ formatMessage(messages.unsavedBody) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirmLeave">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.leaveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="unsavedModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.stayButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<EmptyState
|
||||
v-if="loadError"
|
||||
type="error"
|
||||
:heading="formatMessage(messages.loadErrorHeading)"
|
||||
:description="loadError"
|
||||
/>
|
||||
<div v-else-if="data" class="flex flex-col gap-6 pb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="group relative">
|
||||
<Avatar :src="form.removeIcon ? undefined : data.icon" size="64px" />
|
||||
<Tooltip>
|
||||
<button
|
||||
v-if="data.icon && !form.removeIcon && !readonly"
|
||||
class="absolute inset-0 hidden cursor-pointer items-center justify-center rounded-xl border-none bg-black/60 text-white group-hover:flex"
|
||||
@click="form.removeIcon = true"
|
||||
>
|
||||
<UndoIcon class="size-5" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.resetIcon) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<h1 class="m-0 truncate text-2xl font-extrabold text-contrast">{{ data.name }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-secondary">
|
||||
<span v-if="data.version_name" class="rounded-full bg-button-bg px-2 py-0.5 font-medium">
|
||||
{{ data.version_name }}
|
||||
</span>
|
||||
<span v-if="data.modded" class="rounded-full bg-button-bg px-2 py-0.5 font-medium">
|
||||
{{ formatMessage(messages.moddedBadge) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="data.hardcore"
|
||||
class="rounded-full bg-bg-red px-2 py-0.5 font-medium text-red"
|
||||
>
|
||||
{{ formatMessage(messages.hardcoreBadge) }}
|
||||
</span>
|
||||
<span v-if="data.last_played">
|
||||
{{
|
||||
formatMessage(messages.lastPlayed, {
|
||||
ago: formatRelativeTime(dayjs(data.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Admonition v-if="readonly" type="warning" :header="formatMessage(messages.lockedHeading)">
|
||||
{{ formatMessage(messages.lockedBody) }}
|
||||
</Admonition>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.basicSection) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="font-semibold text-contrast" for="world-name">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="world-name"
|
||||
v-model="form.name"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
autocomplete="off"
|
||||
:disabled="readonly"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span v-if="nameError" class="text-sm text-red">
|
||||
{{ formatMessage(messages.nameRequired) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameModeLabel) }}
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.gameMode"
|
||||
name="world-game-mode"
|
||||
:options="GAME_MODE_OPTIONS"
|
||||
:display-name="gameModeLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.difficulty" class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.difficultyLabel) }}
|
||||
<span v-if="data.difficulty_locked" class="font-normal text-secondary">
|
||||
{{ formatMessage(messages.difficultyLockedHint) }}
|
||||
</span>
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.difficulty"
|
||||
name="world-difficulty"
|
||||
:options="DIFFICULTY_OPTIONS"
|
||||
:display-name="difficultyLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.allowCommands !== undefined" class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.allowCommandsLabel) }}
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.allowCommands"
|
||||
name="world-allow-commands"
|
||||
:options="BOOLEAN_OPTIONS"
|
||||
:display-name="booleanLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="form.seed !== undefined" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.seedSection) }}
|
||||
</h2>
|
||||
<Admonition type="warning" :header="formatMessage(messages.seedWarningHeading)">
|
||||
{{ formatMessage(messages.seedWarningBody) }}
|
||||
</Admonition>
|
||||
<div class="flex max-w-md flex-col gap-1.5">
|
||||
<StyledInput
|
||||
v-model="form.seed"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="font-mono"
|
||||
:disabled="readonly"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span v-if="seedError" class="text-sm text-red">
|
||||
{{ formatMessage(messages.seedInvalid) }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.game_rules.length > 0" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.gameRulesSection) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
v-model="ruleSearch"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchRulesPlaceholder, { count: data.game_rules.length })
|
||||
"
|
||||
wrapper-class="max-w-md"
|
||||
/>
|
||||
<div v-if="ruleGroups.length === 0" class="text-secondary">
|
||||
{{ formatMessage(messages.noRulesFound) }}
|
||||
</div>
|
||||
<div v-for="group in ruleGroups" :key="group.category" class="flex flex-col gap-2">
|
||||
<Accordion
|
||||
:open-by-default="false"
|
||||
:force-open="ruleSearchActive"
|
||||
class="min-w-0 overflow-hidden rounded-xl border border-solid border-surface-4 bg-bg-raised"
|
||||
button-class="group flex w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-4 py-3 text-left"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="m-0 text-base font-bold text-contrast">{{ group.label }}</h3>
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-surface-4">
|
||||
<div
|
||||
v-for="rule in group.rules"
|
||||
:key="rule.key"
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-solid border-surface-4 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<span
|
||||
v-if="rule.modifiedFromDefault"
|
||||
class="size-2 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.modifiedFromDefault) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<span class="truncate font-medium text-contrast" :title="rule.key">
|
||||
{{ rule.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ButtonStyled v-if="rule.canResetToDefault" type="transparent" size="small">
|
||||
<Tooltip>
|
||||
<button
|
||||
:disabled="readonly"
|
||||
@click="resetRuleToDefault(rule.key, rule.defaultValue)"
|
||||
>
|
||||
<UndoIcon />
|
||||
</button>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.resetRuleToDefault) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</ButtonStyled>
|
||||
<DropdownSelect
|
||||
v-if="rule.widget === 'boolean'"
|
||||
v-model="form.rules[rule.key]"
|
||||
:name="`gamerule-${rule.key}`"
|
||||
class="!w-36"
|
||||
:options="BOOLEAN_OPTIONS"
|
||||
:display-name="booleanLabel"
|
||||
:disabled="readonly"
|
||||
render-up
|
||||
/>
|
||||
<StyledInput
|
||||
v-else
|
||||
v-model="form.rules[rule.key]"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:disabled="readonly"
|
||||
input-class="font-mono !h-9"
|
||||
wrapper-class="w-36"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="invalidRules.includes(rule.key)" class="w-full text-sm text-red">
|
||||
{{ formatMessage(messages.ruleInvalidInteger) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="dirty && !readonly"
|
||||
class="sticky bottom-0 z-10 -mx-2 flex flex-wrap items-center gap-3 rounded-t-xl border border-b-0 border-solid border-button-border bg-bg-raised px-4 py-3 shadow-lg"
|
||||
>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.unsavedChangesLabel) }}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSave" @click="save">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="saving" @click="discard">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.discardButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, SearchIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, type RouteLocationNormalized, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import {
|
||||
gameRuleCategoryMessages,
|
||||
getGameRuleMetadata,
|
||||
resolveGameRuleType,
|
||||
} from '@/components/ui/world/gameRuleRegistry.ts'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
get_world_level_data,
|
||||
reset_world_icon,
|
||||
type SingleplayerGameMode,
|
||||
update_world_settings,
|
||||
type WorldDifficulty,
|
||||
type WorldLevelData,
|
||||
type WorldSettingsPatch,
|
||||
} from '@/helpers/worlds.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options?: unknown
|
||||
offline?: boolean
|
||||
playing?: boolean
|
||||
installed?: boolean
|
||||
}>()
|
||||
|
||||
const worldPath = computed(() => decodeURIComponent(String(route.params.world ?? '')))
|
||||
|
||||
const GAME_MODE_OPTIONS: SingleplayerGameMode[] = ['survival', 'creative', 'adventure', 'spectator']
|
||||
const DIFFICULTY_OPTIONS: WorldDifficulty[] = ['peaceful', 'easy', 'normal', 'hard']
|
||||
const BOOLEAN_OPTIONS = ['true', 'false']
|
||||
|
||||
const RULE_CATEGORY_ORDER = [
|
||||
'player',
|
||||
'mobs',
|
||||
'drops',
|
||||
'world',
|
||||
'chat',
|
||||
'commands',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
gameMode: SingleplayerGameMode
|
||||
difficulty?: WorldDifficulty
|
||||
allowCommands?: string
|
||||
seed?: string
|
||||
rules: Record<string, string>
|
||||
removeIcon: boolean
|
||||
}
|
||||
|
||||
const data = ref<WorldLevelData>()
|
||||
const form = ref<FormState>(emptyForm())
|
||||
const savedState = ref<FormState>(emptyForm())
|
||||
const loadError = ref<string>()
|
||||
const saving = ref(false)
|
||||
const ruleSearch = ref('')
|
||||
|
||||
const ruleSearchActive = computed(() => ruleSearch.value.trim().length > 0)
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { name: '', gameMode: 'survival', rules: {}, removeIcon: false }
|
||||
}
|
||||
|
||||
function snapshotForm(level: WorldLevelData): FormState {
|
||||
return {
|
||||
name: level.name,
|
||||
gameMode: level.game_mode,
|
||||
difficulty: level.difficulty,
|
||||
allowCommands: level.allow_commands === undefined ? undefined : String(level.allow_commands),
|
||||
seed: level.seed,
|
||||
rules: Object.fromEntries(level.game_rules.map((rule) => [rule.key, rule.value])),
|
||||
removeIcon: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const level = await get_world_level_data(props.instance.id, worldPath.value)
|
||||
data.value = level
|
||||
form.value = snapshotForm(level)
|
||||
savedState.value = snapshotForm(level)
|
||||
loadError.value = undefined
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
await load()
|
||||
|
||||
watch(
|
||||
() => props.playing,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
setTimeout(() => {
|
||||
if (!dirty.value) {
|
||||
load()
|
||||
} else if (data.value) {
|
||||
reloadLockedStateOnly()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function reloadLockedStateOnly() {
|
||||
try {
|
||||
const level = await get_world_level_data(props.instance.id, worldPath.value)
|
||||
if (data.value) {
|
||||
data.value.locked = level.locked
|
||||
}
|
||||
} catch {
|
||||
// Keep the current editor state when only the lock probe fails
|
||||
}
|
||||
}
|
||||
|
||||
const readonly = computed(() => data.value?.locked ?? true)
|
||||
|
||||
const dirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(savedState.value))
|
||||
|
||||
const nameError = computed(() => form.value.name.trim().length === 0)
|
||||
|
||||
const I64_MIN = -(2n ** 63n)
|
||||
const I64_MAX = 2n ** 63n - 1n
|
||||
|
||||
const seedError = computed(() => {
|
||||
if (form.value.seed === undefined || form.value.seed === savedState.value.seed) {
|
||||
return false
|
||||
}
|
||||
const trimmed = form.value.seed.trim()
|
||||
if (!/^[+-]?\d+$/.test(trimmed)) {
|
||||
return true
|
||||
}
|
||||
const value = BigInt(trimmed)
|
||||
return value < I64_MIN || value > I64_MAX
|
||||
})
|
||||
|
||||
const invalidRules = computed(() =>
|
||||
Object.entries(form.value.rules)
|
||||
.filter(([key, value]) => {
|
||||
if (value === savedState.value.rules[key]) {
|
||||
return false
|
||||
}
|
||||
const widget = resolveGameRuleType(savedState.value.rules[key] ?? value)
|
||||
return widget === 'integer' && !/^[+-]?\d+$/.test(value.trim())
|
||||
})
|
||||
.map(([key]) => key),
|
||||
)
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
dirty.value &&
|
||||
!saving.value &&
|
||||
!nameError.value &&
|
||||
!seedError.value &&
|
||||
invalidRules.value.length === 0,
|
||||
)
|
||||
|
||||
type RuleRow = {
|
||||
key: string
|
||||
label: string
|
||||
widget: 'boolean' | 'integer' | 'text'
|
||||
modifiedFromDefault: boolean
|
||||
canResetToDefault: boolean
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
const ruleGroups = computed(() => {
|
||||
if (!data.value) {
|
||||
return []
|
||||
}
|
||||
const query = ruleSearch.value.trim().toLowerCase()
|
||||
const grouped = new Map<string, RuleRow[]>()
|
||||
|
||||
for (const entry of data.value.game_rules) {
|
||||
const meta = getGameRuleMetadata(entry.key)
|
||||
const label = meta ? formatMessage(meta.name) : entry.key
|
||||
if (query && !label.toLowerCase().includes(query) && !entry.key.toLowerCase().includes(query)) {
|
||||
continue
|
||||
}
|
||||
const currentValue = form.value.rules[entry.key] ?? entry.value
|
||||
const category = meta?.category ?? 'other'
|
||||
const row: RuleRow = {
|
||||
key: entry.key,
|
||||
label,
|
||||
widget: resolveGameRuleType(entry.value),
|
||||
modifiedFromDefault: meta?.defaultValue !== undefined && currentValue !== meta.defaultValue,
|
||||
canResetToDefault: meta?.defaultValue !== undefined && currentValue !== meta.defaultValue,
|
||||
defaultValue: meta?.defaultValue,
|
||||
}
|
||||
const rows = grouped.get(category)
|
||||
if (rows) {
|
||||
rows.push(row)
|
||||
} else {
|
||||
grouped.set(category, [row])
|
||||
}
|
||||
}
|
||||
|
||||
return RULE_CATEGORY_ORDER.filter((category) => grouped.has(category)).map((category) => ({
|
||||
category,
|
||||
label: formatMessage(gameRuleCategoryMessages[category]),
|
||||
rules: grouped.get(category)!,
|
||||
}))
|
||||
})
|
||||
|
||||
function gameModeLabel(mode: SingleplayerGameMode) {
|
||||
return formatMessage(GAME_MODES[mode].message)
|
||||
}
|
||||
|
||||
function difficultyLabel(difficulty: WorldDifficulty) {
|
||||
return formatMessage(messages[`difficulty_${difficulty}`])
|
||||
}
|
||||
|
||||
function booleanLabel(value: string) {
|
||||
return formatMessage(value === 'true' ? messages.ruleEnabled : messages.ruleDisabled)
|
||||
}
|
||||
|
||||
function buildPatch(): WorldSettingsPatch {
|
||||
const patch: WorldSettingsPatch = {}
|
||||
const current = form.value
|
||||
const saved = savedState.value
|
||||
|
||||
if (current.name.trim() !== saved.name) {
|
||||
patch.name = current.name.trim()
|
||||
}
|
||||
if (current.gameMode !== saved.gameMode) {
|
||||
patch.game_mode = current.gameMode
|
||||
}
|
||||
if (current.difficulty && current.difficulty !== saved.difficulty) {
|
||||
patch.difficulty = current.difficulty
|
||||
}
|
||||
if (current.allowCommands !== undefined && current.allowCommands !== saved.allowCommands) {
|
||||
patch.allow_commands = current.allowCommands === 'true'
|
||||
}
|
||||
if (current.seed !== undefined && current.seed.trim() !== saved.seed) {
|
||||
patch.seed = current.seed.trim()
|
||||
}
|
||||
const changedRules = Object.entries(current.rules)
|
||||
.filter(([key, value]) => value !== saved.rules[key])
|
||||
.map(([key, value]) => ({ key, value: value.trim() }))
|
||||
if (changedRules.length > 0) {
|
||||
patch.game_rules = changedRules
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value || !data.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const patch = buildPatch()
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await update_world_settings(props.instance.id, worldPath.value, patch)
|
||||
}
|
||||
if (form.value.removeIcon && data.value.icon) {
|
||||
await reset_world_icon(props.instance.id, worldPath.value)
|
||||
}
|
||||
await load()
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', props.instance.id] })
|
||||
addNotification({
|
||||
title: formatMessage(messages.savedNotification),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = JSON.parse(JSON.stringify(savedState.value))
|
||||
}
|
||||
|
||||
function resetRuleToDefault(key: string, defaultValue?: string) {
|
||||
if (defaultValue !== undefined) {
|
||||
form.value.rules[key] = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const unsavedModal = ref<InstanceType<typeof ModalWrapper>>()
|
||||
let allowLeave = false
|
||||
let pendingNavigation: RouteLocationNormalized | null = null
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!dirty.value || readonly.value || allowLeave) {
|
||||
return true
|
||||
}
|
||||
pendingNavigation = to
|
||||
unsavedModal.value?.show()
|
||||
return false
|
||||
})
|
||||
|
||||
function confirmLeave() {
|
||||
allowLeave = true
|
||||
unsavedModal.value?.hide()
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
loadErrorHeading: {
|
||||
id: 'app.world-editor.load-error',
|
||||
defaultMessage: 'Failed to load world',
|
||||
},
|
||||
moddedBadge: {
|
||||
id: 'app.world-editor.badge.modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
hardcoreBadge: {
|
||||
id: 'app.world-editor.badge.hardcore',
|
||||
defaultMessage: 'Hardcore',
|
||||
},
|
||||
lastPlayed: {
|
||||
id: 'app.world-editor.last-played',
|
||||
defaultMessage: 'Last played {ago}',
|
||||
},
|
||||
lockedHeading: {
|
||||
id: 'app.world-editor.locked.heading',
|
||||
defaultMessage: 'World is in use',
|
||||
},
|
||||
lockedBody: {
|
||||
id: 'app.world-editor.locked.body',
|
||||
defaultMessage:
|
||||
'This world is currently open in Minecraft. Close the world before editing it — the editor is read-only until then.',
|
||||
},
|
||||
basicSection: {
|
||||
id: 'app.world-editor.section.basic',
|
||||
defaultMessage: 'Basic settings',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'app.world-editor.name.label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'app.world-editor.name.placeholder',
|
||||
defaultMessage: 'Minecraft World',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'app.world-editor.name.required',
|
||||
defaultMessage: 'The world name cannot be empty',
|
||||
},
|
||||
gameModeLabel: {
|
||||
id: 'app.world-editor.game-mode.label',
|
||||
defaultMessage: 'Game mode',
|
||||
},
|
||||
difficultyLabel: {
|
||||
id: 'app.world-editor.difficulty.label',
|
||||
defaultMessage: 'Difficulty',
|
||||
},
|
||||
difficultyLockedHint: {
|
||||
id: 'app.world-editor.difficulty.locked-hint',
|
||||
defaultMessage: '(locked in game)',
|
||||
},
|
||||
difficulty_peaceful: {
|
||||
id: 'app.world-editor.difficulty.peaceful',
|
||||
defaultMessage: 'Peaceful',
|
||||
},
|
||||
difficulty_easy: {
|
||||
id: 'app.world-editor.difficulty.easy',
|
||||
defaultMessage: 'Easy',
|
||||
},
|
||||
difficulty_normal: {
|
||||
id: 'app.world-editor.difficulty.normal',
|
||||
defaultMessage: 'Normal',
|
||||
},
|
||||
difficulty_hard: {
|
||||
id: 'app.world-editor.difficulty.hard',
|
||||
defaultMessage: 'Hard',
|
||||
},
|
||||
allowCommandsLabel: {
|
||||
id: 'app.world-editor.allow-commands.label',
|
||||
defaultMessage: 'Allow cheats',
|
||||
},
|
||||
seedSection: {
|
||||
id: 'app.world-editor.section.seed',
|
||||
defaultMessage: 'World seed',
|
||||
},
|
||||
seedWarningHeading: {
|
||||
id: 'app.world-editor.seed.warning-heading',
|
||||
defaultMessage: 'Changing the seed only affects new terrain',
|
||||
},
|
||||
seedWarningBody: {
|
||||
id: 'app.world-editor.seed.warning-body',
|
||||
defaultMessage:
|
||||
'Chunks that have already been generated will not be regenerated, which can create visible borders between old and new terrain.',
|
||||
},
|
||||
seedInvalid: {
|
||||
id: 'app.world-editor.seed.invalid',
|
||||
defaultMessage: 'The seed must be a whole number in the 64-bit integer range',
|
||||
},
|
||||
gameRulesSection: {
|
||||
id: 'app.world-editor.section.game-rules',
|
||||
defaultMessage: 'Game rules',
|
||||
},
|
||||
searchRulesPlaceholder: {
|
||||
id: 'app.world-editor.game-rules.search-placeholder',
|
||||
defaultMessage: 'Search {count} game rules...',
|
||||
},
|
||||
noRulesFound: {
|
||||
id: 'app.world-editor.game-rules.no-results',
|
||||
defaultMessage: 'No game rules match your search',
|
||||
},
|
||||
modifiedFromDefault: {
|
||||
id: 'app.world-editor.game-rules.modified',
|
||||
defaultMessage: 'Differs from the vanilla default',
|
||||
},
|
||||
resetRuleToDefault: {
|
||||
id: 'app.world-editor.game-rules.reset-to-default',
|
||||
defaultMessage: 'Reset to default',
|
||||
},
|
||||
ruleEnabled: {
|
||||
id: 'app.world-editor.game-rules.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
ruleDisabled: {
|
||||
id: 'app.world-editor.game-rules.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
ruleInvalidInteger: {
|
||||
id: 'app.world-editor.game-rules.invalid-integer',
|
||||
defaultMessage: 'This rule requires a whole number',
|
||||
},
|
||||
resetIcon: {
|
||||
id: 'app.world-editor.reset-icon',
|
||||
defaultMessage: 'Reset icon',
|
||||
},
|
||||
unsavedChangesLabel: {
|
||||
id: 'app.world-editor.unsaved-changes',
|
||||
defaultMessage: 'You have unsaved changes',
|
||||
},
|
||||
discardButton: {
|
||||
id: 'app.world-editor.discard',
|
||||
defaultMessage: 'Discard changes',
|
||||
},
|
||||
savedNotification: {
|
||||
id: 'app.world-editor.saved',
|
||||
defaultMessage: 'World settings saved',
|
||||
},
|
||||
unsavedTitle: {
|
||||
id: 'app.world-editor.unsaved-modal.title',
|
||||
defaultMessage: 'Discard unsaved changes?',
|
||||
},
|
||||
unsavedBody: {
|
||||
id: 'app.world-editor.unsaved-modal.body',
|
||||
defaultMessage: 'Your changes to this world have not been saved and will be lost if you leave.',
|
||||
},
|
||||
leaveButton: {
|
||||
id: 'app.world-editor.unsaved-modal.leave',
|
||||
defaultMessage: 'Discard and leave',
|
||||
},
|
||||
stayButton: {
|
||||
id: 'app.world-editor.unsaved-modal.stay',
|
||||
defaultMessage: 'Keep editing',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
775
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
775
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<AddServerModal
|
||||
ref="addServerModal"
|
||||
:instance="instance"
|
||||
@submit="
|
||||
(server, start) => {
|
||||
addServer(server)
|
||||
if (start) {
|
||||
joinWorld(server)
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
|
||||
<ConfirmRemoveWorldModal
|
||||
ref="removeWorldModal"
|
||||
:world="worldToRemove"
|
||||
:symlink-target="instance.symlink_target"
|
||||
@confirm="proceedRemoveWorld"
|
||||
/>
|
||||
<ReadyTransition :pending="worldsReadyPending">
|
||||
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
|
||||
"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({
|
||||
path: '/browse/world',
|
||||
query: { i: instance.id, from: 'world-maps' },
|
||||
})
|
||||
"
|
||||
>
|
||||
<WorldIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseMaps) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<button
|
||||
:class="filterPillClass(selectedFilters.length === 0)"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
:class="filterPillClass(selectedFilters.includes(option.id))"
|
||||
@click="toggleFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<WorldItem
|
||||
v-for="world in filteredWorlds"
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-server-quick-play="supportsServerQuickPlay"
|
||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
:starting-instance="startingInstance"
|
||||
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
||||
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
||||
:rendered-motd="
|
||||
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
||||
"
|
||||
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||
:shortcut-instance-id="instance.id"
|
||||
@update="refreshAllWorlds"
|
||||
@play="() => joinWorld(world)"
|
||||
@stop="() => emit('stop')"
|
||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||
@edit="
|
||||
() =>
|
||||
world.type === 'singleplayer'
|
||||
? router.push(
|
||||
`/instance/${encodeURIComponent(instance.id)}/worlds/${encodeURIComponent(world.path)}/edit`,
|
||||
)
|
||||
: isManagedServerWorld(world)
|
||||
? undefined
|
||||
: editServerModal?.show(world)
|
||||
"
|
||||
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
|
||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.id, world.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="empty-inbox"
|
||||
:heading="formatMessage(messages.noWorldsHeading)"
|
||||
:description="formatMessage(messages.noWorldsDescription)"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!h-10" @click="addServerModal?.show()">
|
||||
<PlusIcon class="size-5" />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/world', query: { i: instance.id, from: 'world-maps' } })
|
||||
"
|
||||
>
|
||||
<WorldIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseMaps) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</ReadyTransition>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CompassIcon,
|
||||
FilterIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
type GameVersion,
|
||||
injectNotificationManager,
|
||||
ReadyTransition,
|
||||
StyledInput,
|
||||
useReadyState,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_project_v3 } from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
|
||||
import {
|
||||
delete_world,
|
||||
get_instance_protocol_version,
|
||||
getServerDomainKey,
|
||||
getWorldIdentifier,
|
||||
handleDefaultInstanceUpdateEvent,
|
||||
hasServerQuickPlaySupport,
|
||||
hasWorldQuickPlaySupport,
|
||||
type InstanceEvent,
|
||||
normalizeServerAddress,
|
||||
type ProtocolVersion,
|
||||
refreshServerData,
|
||||
refreshServers,
|
||||
refreshWorld,
|
||||
refreshWorlds,
|
||||
remove_server_from_instance,
|
||||
resolveManagedServerWorld,
|
||||
type ServerData,
|
||||
type ServerWorld,
|
||||
showWorldInFolder,
|
||||
type SingleplayerWorld,
|
||||
sortWorlds,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
type World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const messages = defineMessages({
|
||||
searchWorldsPlaceholder: {
|
||||
id: 'app.instance.worlds.search-worlds-placeholder',
|
||||
defaultMessage: 'Search {count} worlds...',
|
||||
},
|
||||
addServer: {
|
||||
id: 'app.instance.worlds.add-server',
|
||||
defaultMessage: 'Add server',
|
||||
},
|
||||
browseServers: {
|
||||
id: 'app.instance.worlds.browse-servers',
|
||||
defaultMessage: 'Browse servers',
|
||||
},
|
||||
browseMaps: {
|
||||
id: 'app.instance.worlds.browse-maps',
|
||||
defaultMessage: 'Browse maps',
|
||||
},
|
||||
noWorldsHeading: {
|
||||
id: 'app.instance.worlds.no-worlds-heading',
|
||||
defaultMessage: 'No servers or worlds added',
|
||||
},
|
||||
noWorldsDescription: {
|
||||
id: 'app.instance.worlds.no-worlds-description',
|
||||
defaultMessage: 'Add a server or browse to get started',
|
||||
},
|
||||
vanillaFilter: {
|
||||
id: 'app.instance.worlds.filter-vanilla',
|
||||
defaultMessage: 'Vanilla',
|
||||
},
|
||||
moddedFilter: {
|
||||
id: 'app.instance.worlds.filter-modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
onlineFilter: {
|
||||
id: 'app.instance.worlds.filter-online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offlineFilter: {
|
||||
id: 'app.instance.worlds.filter-offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
|
||||
|
||||
const worldToRemove = ref<World | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'play', world: World): void
|
||||
(event: 'stop'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu> | null
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
const instance = computed(() => props.instance)
|
||||
const playing = computed(() => props.playing)
|
||||
|
||||
function play(world: World) {
|
||||
emit('play', world)
|
||||
}
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const searchFilter = ref('')
|
||||
|
||||
function filterPillClass(isActive: boolean) {
|
||||
return [
|
||||
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
|
||||
isActive
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
|
||||
]
|
||||
}
|
||||
|
||||
function toggleFilter(id: string) {
|
||||
const idx = selectedFilters.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
selectedFilters.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(id)
|
||||
if (id === 'singleplayer') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'online' && f !== 'offline')
|
||||
} else if (id === 'online' || id === 'offline') {
|
||||
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'singleplayer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
const worldPlaying = ref<World>()
|
||||
|
||||
const worldsQuery = useQuery({
|
||||
queryKey: computed(() => ['worlds', instance.value.id]),
|
||||
queryFn: () => refreshWorlds(instance.value.id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const worldsReadyPending = useReadyState(worldsQuery)
|
||||
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
// Track servers_updated calls on Linux to prevent server ping spam
|
||||
const MAX_LINUX_REFRESHES = 3
|
||||
const isLinux = platform() === 'linux'
|
||||
const linuxRefreshCount = ref(0)
|
||||
|
||||
const protocolVersion = ref<ProtocolVersion | null>(null)
|
||||
const protocolVersionReady = ref(false)
|
||||
|
||||
const gameVersions = ref<GameVersion[]>([])
|
||||
const supportsServerQuickPlay = computed(() =>
|
||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
const supportsWorldQuickPlay = computed(() =>
|
||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => worldsQuery.data.value,
|
||||
(data) => {
|
||||
if (data) {
|
||||
worlds.value = [...data]
|
||||
hadNoWorlds.value = worlds.value.length === 0
|
||||
if (!refreshingAll.value) {
|
||||
void refreshServers(
|
||||
worlds.value,
|
||||
serverData.value,
|
||||
protocolVersion.value,
|
||||
protocolVersionReady.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const managedServerName = ref<string | null>(null)
|
||||
const managedServerAddress = ref<string | null>(null)
|
||||
|
||||
const managedServerWorld = computed(() =>
|
||||
resolveManagedServerWorld(worlds.value, managedServerName.value, managedServerAddress.value),
|
||||
)
|
||||
|
||||
function isManagedServerWorld(world: World): world is ServerWorld {
|
||||
return world.type === 'server' && managedServerWorld.value?.index === world.index
|
||||
}
|
||||
|
||||
async function refreshManagedServerMetadata() {
|
||||
await ensureManagedServerWorldExists(
|
||||
instance.value.id,
|
||||
managedServerName.value,
|
||||
managedServerAddress.value,
|
||||
)
|
||||
|
||||
const projectId = instance.value.link?.project_id
|
||||
if (!projectId) {
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [project, projectV3] = await Promise.all([
|
||||
get_project(projectId),
|
||||
get_project_v3(projectId),
|
||||
])
|
||||
|
||||
if (projectV3?.minecraft_server == null) {
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const serverAddress = getServerAddress(projectV3.minecraft_java_server)
|
||||
if (!serverAddress) {
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
return
|
||||
}
|
||||
|
||||
managedServerName.value = project.title
|
||||
managedServerAddress.value = serverAddress
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve managed server metadata for instance: ${instance.value.id}`,
|
||||
err,
|
||||
)
|
||||
managedServerName.value = null
|
||||
managedServerAddress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value.link?.project_id,
|
||||
async () => {
|
||||
await refreshManagedServerMetadata()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
let unlistenInstance: (() => void) | null = null
|
||||
let worldsTabAlive = true
|
||||
|
||||
async function initWorldsTab() {
|
||||
const [_unlistenInstance, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
|
||||
instance_listener(async (e: InstanceEvent) => {
|
||||
if (e.instance_id !== instance.value.id) return
|
||||
|
||||
console.info(`Handling instance event '${e.event}' for instance: ${e.instance_id}`)
|
||||
|
||||
if (e.event === 'servers_updated') {
|
||||
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
|
||||
if (isLinux) linuxRefreshCount.value++
|
||||
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
|
||||
await handleDefaultInstanceUpdateEvent(worlds.value, instance.value.id, e)
|
||||
}),
|
||||
get_instance_protocol_version(instance.value.id).catch(() => null),
|
||||
get_game_versions().catch(() => [] as GameVersion[]),
|
||||
])
|
||||
|
||||
if (!worldsTabAlive) {
|
||||
_unlistenInstance()
|
||||
return
|
||||
}
|
||||
|
||||
unlistenInstance = _unlistenInstance
|
||||
protocolVersion.value = resolvedProtocolVersion
|
||||
gameVersions.value = resolvedGameVersions
|
||||
protocolVersionReady.value = true
|
||||
|
||||
if (worlds.value.length > 0) {
|
||||
void refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
}
|
||||
}
|
||||
|
||||
await initWorldsTab()
|
||||
|
||||
async function refreshServer(address: string) {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
}
|
||||
if (!protocolVersionReady.value) return
|
||||
await refreshServerData(serverData.value[address], protocolVersion.value, address)
|
||||
}
|
||||
|
||||
async function refreshAllWorlds() {
|
||||
if (refreshingAll.value) {
|
||||
console.log(`Already refreshing, cancelling refresh.`)
|
||||
return
|
||||
}
|
||||
|
||||
refreshingAll.value = true
|
||||
try {
|
||||
for (const world of worlds.value) {
|
||||
if (world.type === 'server') {
|
||||
if (!serverData.value[world.address]) {
|
||||
serverData.value[world.address] = { refreshing: true }
|
||||
} else {
|
||||
serverData.value[world.address].refreshing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
|
||||
await refreshServers(
|
||||
worlds.value,
|
||||
serverData.value,
|
||||
protocolVersion.value,
|
||||
protocolVersionReady.value,
|
||||
)
|
||||
} finally {
|
||||
refreshingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(server: ServerWorld) {
|
||||
worlds.value.push(server)
|
||||
sortWorlds(worlds.value)
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
|
||||
async function editServer(server: ServerWorld) {
|
||||
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
|
||||
if (index !== -1) {
|
||||
const oldServer = worlds.value[index] as ServerWorld
|
||||
worlds.value[index] = server
|
||||
sortWorlds(worlds.value)
|
||||
if (oldServer.address !== server.address) {
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
} else {
|
||||
handleError(new Error(`Error refreshing server, refreshing all worlds`))
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(server: ServerWorld) {
|
||||
await remove_server_from_instance(instance.value.id, server.index).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
|
||||
let serverIdx = 0
|
||||
for (const w of worlds.value) {
|
||||
if (w.type === 'server') {
|
||||
w.index = serverIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorld(world: SingleplayerWorld) {
|
||||
await delete_world(instance.value.id, world.path).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
|
||||
}
|
||||
|
||||
async function handleJoinError(err: Error) {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: instance.value.id,
|
||||
instance_name: instance.value.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: instance.value.id })
|
||||
startingInstance.value = false
|
||||
worldPlaying.value = undefined
|
||||
}
|
||||
|
||||
async function joinWorld(world: World) {
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
startingInstance.value = true
|
||||
worldPlaying.value = world
|
||||
if (world.type === 'server') {
|
||||
const managedProjectId = instance.value.link?.project_id
|
||||
if (managedProjectId && isManagedServerWorld(world)) {
|
||||
await playServerProject(managedProjectId).catch(handleJoinError)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'WorldsPage',
|
||||
})
|
||||
startingInstance.value = false
|
||||
return
|
||||
}
|
||||
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'WorldsPage',
|
||||
})
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
|
||||
}
|
||||
play(world)
|
||||
startingInstance.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => playing.value,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
worldPlaying.value = undefined
|
||||
|
||||
setTimeout(async () => {
|
||||
for (const world of worlds.value) {
|
||||
if (world.type === 'singleplayer' && world.locked) {
|
||||
await refreshWorld(worlds.value, instance.value.id, world.path)
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function worldsMatch(world: World, other: World | undefined) {
|
||||
if (world.type === 'server' && other?.type === 'server') {
|
||||
return world.address === other.address
|
||||
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
|
||||
return world.path === other.path
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const dedupedWorlds = computed(() => {
|
||||
const visibleWorlds: World[] = []
|
||||
const serverIndexByDomain = new Map<string, number>()
|
||||
|
||||
for (const world of worlds.value) {
|
||||
if (world.type !== 'server') {
|
||||
visibleWorlds.push(world)
|
||||
continue
|
||||
}
|
||||
|
||||
const domainKey =
|
||||
getServerDomainKey(world.address) ||
|
||||
normalizeServerAddress(world.address) ||
|
||||
`server-${world.index}`
|
||||
const existingIndex = serverIndexByDomain.get(domainKey)
|
||||
|
||||
if (existingIndex == null) {
|
||||
serverIndexByDomain.set(domainKey, visibleWorlds.length)
|
||||
visibleWorlds.push(world)
|
||||
continue
|
||||
}
|
||||
|
||||
// replace world with managed world if applicable
|
||||
const existingWorld = visibleWorlds[existingIndex]
|
||||
if (
|
||||
existingWorld?.type === 'server' &&
|
||||
!isManagedServerWorld(existingWorld) &&
|
||||
isManagedServerWorld(world)
|
||||
) {
|
||||
visibleWorlds[existingIndex] = world
|
||||
}
|
||||
}
|
||||
|
||||
return visibleWorlds
|
||||
})
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options: { id: string; label: string }[] = []
|
||||
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
|
||||
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (hasSingleplayer && hasServer) {
|
||||
options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
const servers = dedupedWorlds.value.filter((x) => x.type === 'server')
|
||||
const hasVanilla = servers.some((x) => x.content_kind !== 'modpack')
|
||||
const hasModded = servers.some((x) => x.content_kind === 'modpack')
|
||||
if (hasVanilla && hasModded) {
|
||||
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
|
||||
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
|
||||
}
|
||||
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
|
||||
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
|
||||
if (hasOnline && hasOffline) {
|
||||
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
|
||||
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
watch(filterOptions, (options) => {
|
||||
const validIds = new Set(options.map((opt) => opt.id))
|
||||
const cleaned = selectedFilters.value.filter((f) => validIds.has(f))
|
||||
if (cleaned.length !== selectedFilters.value.length) {
|
||||
selectedFilters.value = cleaned
|
||||
}
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() =>
|
||||
dedupedWorlds.value.filter((x) => {
|
||||
if (searchFilter.value && !x.name.toLowerCase().includes(searchFilter.value.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedFilters.value.length === 0) return true
|
||||
|
||||
const hasSingleplayerFilter = selectedFilters.value.includes('singleplayer')
|
||||
const typeFilters = selectedFilters.value.filter((f) => f === 'vanilla' || f === 'modded')
|
||||
const statusFilters = selectedFilters.value.filter((f) => f === 'online' || f === 'offline')
|
||||
|
||||
if (x.type === 'singleplayer') {
|
||||
return hasSingleplayerFilter || (typeFilters.length === 0 && statusFilters.length === 0)
|
||||
}
|
||||
|
||||
if (hasSingleplayerFilter && typeFilters.length === 0 && statusFilters.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let passesType = true
|
||||
if (typeFilters.length > 0) {
|
||||
const isModded = x.content_kind === 'modpack'
|
||||
passesType =
|
||||
(typeFilters.includes('modded') && isModded) ||
|
||||
(typeFilters.includes('vanilla') && !isModded)
|
||||
}
|
||||
|
||||
let passesStatus = true
|
||||
if (statusFilters.length > 0) {
|
||||
const isOnline = !!serverData.value[x.address]?.status
|
||||
passesStatus =
|
||||
(statusFilters.includes('online') && isOnline) ||
|
||||
(statusFilters.includes('offline') && !isOnline)
|
||||
}
|
||||
|
||||
return passesType && passesStatus
|
||||
}),
|
||||
)
|
||||
|
||||
const highlightedWorld = ref(route.query.highlight)
|
||||
|
||||
function promptToRemoveWorld(world: World): boolean {
|
||||
worldToRemove.value = world
|
||||
removeWorldModal.value?.show()
|
||||
return !!removeWorldModal.value
|
||||
}
|
||||
|
||||
async function proceedRemoveWorld(world: World) {
|
||||
if (world.type === 'server') {
|
||||
await removeServer(world)
|
||||
} else {
|
||||
await deleteWorld(world)
|
||||
}
|
||||
worldToRemove.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
worldsTabAlive = false
|
||||
unlistenInstance?.()
|
||||
})
|
||||
</script>
|
||||
10
apps/app-frontend/src/pages/instance/index.js
Normal file
10
apps/app-frontend/src/pages/instance/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Logs from './Logs.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Screenshots from './Screenshots.vue'
|
||||
import WorldEditor from './WorldEditor.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Files, Index, Logs, Mods, Overview, Screenshots, WorldEditor, Worlds }
|
||||
1110
apps/app-frontend/src/pages/instance/upgrade/Compatibility.vue
Normal file
1110
apps/app-frontend/src/pages/instance/upgrade/Compatibility.vue
Normal file
File diff suppressed because it is too large
Load Diff
819
apps/app-frontend/src/pages/instance/upgrade/Confirm.vue
Normal file
819
apps/app-frontend/src/pages/instance/upgrade/Confirm.vue
Normal file
@ -0,0 +1,819 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-0 mt-1 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
</header>
|
||||
|
||||
<Admonition v-if="executionError" type="critical" :header="formatMessage(messages.startError)">
|
||||
{{ executionError }}
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="warning" :header="formatMessage(messages.worldTitle)">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>{{ formatMessage(messages.worldBody) }}</span>
|
||||
<span>{{ formatMessage(messages.datapackNote, { path: datapackPath }) }}</span>
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.environment) }}
|
||||
</h3>
|
||||
<div class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span class="text-secondary">Minecraft</span>
|
||||
<strong
|
||||
>{{ plan.sourceEnvironment.gameVersion }} <span aria-hidden="true">→</span>
|
||||
{{ plan.targetEnvironment.gameVersion }}</strong
|
||||
>
|
||||
<span class="text-secondary">{{ formatMessage(messages.loader) }}</span>
|
||||
<strong>{{ sourceLoader }} <span aria-hidden="true">→</span> {{ targetLoader }}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.strategy) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-3 text-lg font-semibold text-brand">{{ strategyLabel }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-px overflow-hidden rounded-md bg-divider sm:grid-cols-3 lg:grid-cols-6"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="bg-surface-2 p-3">
|
||||
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
|
||||
<div class="text-sm text-secondary">{{ metric.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="sharedInstance" class="order-first flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedTitle) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.sharedDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-solid p-4 text-left"
|
||||
:class="modeClass('direct')"
|
||||
@click="selectMode('direct')"
|
||||
>
|
||||
<strong class="text-contrast">{{ formatMessage(messages.direct) }}</strong>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.directDescription) }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-solid p-4 text-left"
|
||||
:class="modeClass('copy_and_upgrade')"
|
||||
@click="selectMode('copy_and_upgrade')"
|
||||
>
|
||||
<strong class="text-contrast">{{ formatMessage(messages.copy) }}</strong>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.copyDescription) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-solid border-surface-4 bg-surface-2 p-4">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.backupTitle) }}
|
||||
</h3>
|
||||
<template v-if="effectiveMode === 'copy_and_upgrade'">
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">{{ formatMessage(messages.copyNoBackup) }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Checkbox
|
||||
v-model="flow.createFullBackup.value"
|
||||
class="mt-3"
|
||||
:label="formatMessage(messages.backupToggle)"
|
||||
@update:model-value="rememberBackupPreference"
|
||||
/>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.backupDescription) }}
|
||||
</p>
|
||||
<Admonition
|
||||
v-if="!flow.createFullBackup.value"
|
||||
class="mt-3"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.backupOffTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.backupOffBody) }}
|
||||
</Admonition>
|
||||
</template>
|
||||
<div class="mt-4 border-0 border-t border-solid border-divider pt-3 text-sm text-secondary">
|
||||
<strong class="text-contrast">{{ formatMessage(messages.rollbackTitle) }}</strong>
|
||||
<p class="mb-0 mt-1">{{ formatMessage(messages.rollbackDescription) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preservedTitle) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-2 text-sm text-secondary">{{ formatMessage(messages.preservedBody) }}</p>
|
||||
</section>
|
||||
<section v-if="detailGroups.some((group) => group.items.length)" class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">{{ formatMessage(messages.details) }}</h3>
|
||||
<Accordion
|
||||
v-for="group in detailGroups.filter((entry) => entry.items.length)"
|
||||
:key="group.label"
|
||||
button-class="flex w-full items-center gap-2 border-0 bg-transparent p-0 text-left text-contrast"
|
||||
content-class="pt-2"
|
||||
>
|
||||
<template #title>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span class="text-sm text-secondary">{{ group.items.length }}</span>
|
||||
</template>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 items-center gap-3 rounded-md bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar :src="item.icon" :tint-by="item.title" size="2.5rem" no-shadow />
|
||||
<div class="min-w-0 flex-1">
|
||||
<RouterLink
|
||||
v-if="item.projectPath"
|
||||
:to="item.projectPath"
|
||||
class="inline-flex max-w-full cursor-pointer items-center gap-1 font-semibold text-contrast hover:text-brand hover:underline focus-visible:underline"
|
||||
@click="parkProjectReturn"
|
||||
><span class="truncate">{{ item.title }}</span
|
||||
><ExternalIcon class="size-3 shrink-0" aria-hidden="true"
|
||||
/></RouterLink>
|
||||
<div v-else class="truncate font-semibold text-contrast">{{ item.title }}</div>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-2 text-sm text-secondary">
|
||||
<span>{{ item.providerLabel }}</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.projectPath && item.currentReleaseId && item.currentLabel"
|
||||
:label="item.currentLabel"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.currentReleaseId"
|
||||
/>
|
||||
<span v-else-if="item.currentLabel">{{ item.currentLabel }}</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.projectPath && item.targetReleaseId && item.targetLabel"
|
||||
:label="item.targetLabel"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.targetReleaseId"
|
||||
/>
|
||||
<span v-else-if="item.targetLabel">{{ item.targetLabel }}</span>
|
||||
<span v-if="item.stateLabel">{{ item.stateLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Admonition,
|
||||
Avatar,
|
||||
buildUpgradeDisplayNames,
|
||||
Card,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type { InstanceContentSnapshotItem } from '@/helpers/instance'
|
||||
import {
|
||||
type InstanceContentData,
|
||||
loadInstanceContentData,
|
||||
localContentIconUrl,
|
||||
} from '@/helpers/instance-content'
|
||||
import type {
|
||||
ContentProvider,
|
||||
InstanceUpgradeDependencyChange,
|
||||
InstanceUpgradePlanItem,
|
||||
InstanceUpgradeSelection,
|
||||
SharedUpgradeMode,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import { parkUpgradeFlow, upgradeProjectPath } from '@/helpers/upgrade-return-state'
|
||||
import {
|
||||
loadUpgradeProjectDisplayMetadata,
|
||||
loadUpgradeVersionDisplayMetadata,
|
||||
upgradeProjectDisplayCacheKey,
|
||||
type UpgradeProjectIdentity,
|
||||
type UpgradeReleaseIdentity,
|
||||
upgradeVersionDisplayLabel,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
import {
|
||||
confirmSelectionReleaseSlots,
|
||||
confirmSolutionGroups,
|
||||
confirmTargetLoaderLabel,
|
||||
confirmUpgradeOptions,
|
||||
contentIdentityKeys,
|
||||
isSharedUpgradeInstance,
|
||||
normalizeUpgradePath,
|
||||
resolveConfirmDependencyReleases,
|
||||
solutionSummary,
|
||||
upgradeContentDisplayMetadata,
|
||||
} from './analysis'
|
||||
import { attachUpgradeJobToFlow, useInstanceUpgradeFlow } from './flow'
|
||||
import { submitInstanceUpgrade } from './install-job'
|
||||
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.confirm.title', defaultMessage: 'Confirm upgrade' },
|
||||
description: {
|
||||
id: 'instance.upgrade.confirm.description',
|
||||
defaultMessage: 'Review the changes and backup options before starting the upgrade.',
|
||||
},
|
||||
environment: { id: 'instance.upgrade.confirm.environment', defaultMessage: 'Environment' },
|
||||
loader: { id: 'instance.upgrade.confirm.loader', defaultMessage: 'Loader' },
|
||||
automatic: { id: 'instance.upgrade.confirm.loader.automatic', defaultMessage: 'automatic' },
|
||||
strategy: { id: 'instance.upgrade.confirm.strategy', defaultMessage: 'Upgrade strategy' },
|
||||
newest: {
|
||||
id: 'instance.upgrade.confirm.strategy.newest',
|
||||
defaultMessage: 'Update as much as possible',
|
||||
},
|
||||
minimal: {
|
||||
id: 'instance.upgrade.confirm.strategy.minimal',
|
||||
defaultMessage: 'Change as little as possible',
|
||||
},
|
||||
custom: { id: 'instance.upgrade.confirm.strategy.custom', defaultMessage: 'Custom' },
|
||||
updated: { id: 'instance.upgrade.confirm.updated', defaultMessage: 'Will update' },
|
||||
kept: { id: 'instance.upgrade.confirm.kept', defaultMessage: 'Will keep' },
|
||||
disabled: { id: 'instance.upgrade.confirm.disabled', defaultMessage: 'Will disable' },
|
||||
added: {
|
||||
id: 'instance.upgrade.confirm.dependencies-added',
|
||||
defaultMessage: 'Dependencies added',
|
||||
},
|
||||
dependencyUpdated: {
|
||||
id: 'instance.upgrade.confirm.dependencies-updated',
|
||||
defaultMessage: 'Dependencies updated',
|
||||
},
|
||||
removed: {
|
||||
id: 'instance.upgrade.confirm.dependencies-removed',
|
||||
defaultMessage: 'Dependencies removed',
|
||||
},
|
||||
sharedTitle: {
|
||||
id: 'instance.upgrade.confirm.shared.title',
|
||||
defaultMessage: 'Shared instance handling',
|
||||
},
|
||||
sharedDescription: {
|
||||
id: 'instance.upgrade.confirm.shared.description',
|
||||
defaultMessage:
|
||||
'Choose whether to modify the external target or upgrade an independent local copy.',
|
||||
},
|
||||
direct: { id: 'instance.upgrade.confirm.shared.direct', defaultMessage: 'Direct upgrade' },
|
||||
directDescription: {
|
||||
id: 'instance.upgrade.confirm.shared.direct-description',
|
||||
defaultMessage: 'Modify the real external target folder. The existing link remains in place.',
|
||||
},
|
||||
copy: { id: 'instance.upgrade.confirm.shared.copy', defaultMessage: 'Copy and upgrade' },
|
||||
copyDescription: {
|
||||
id: 'instance.upgrade.confirm.shared.copy-description',
|
||||
defaultMessage:
|
||||
'Create and upgrade a new local instance. The original link and external target remain untouched.',
|
||||
},
|
||||
backupTitle: { id: 'instance.upgrade.confirm.backup.title', defaultMessage: 'Full backup' },
|
||||
backupToggle: {
|
||||
id: 'instance.upgrade.confirm.backup.toggle',
|
||||
defaultMessage: 'Create a full backup before upgrading',
|
||||
},
|
||||
backupDescription: {
|
||||
id: 'instance.upgrade.confirm.backup.description',
|
||||
defaultMessage:
|
||||
'A separate instance copy preserves worlds and configuration for a later return.',
|
||||
},
|
||||
copyNoBackup: {
|
||||
id: 'instance.upgrade.confirm.backup.copy-mode',
|
||||
defaultMessage:
|
||||
'The original shared instance will not be modified, so no additional full backup will be created.',
|
||||
},
|
||||
backupOffTitle: {
|
||||
id: 'instance.upgrade.confirm.backup.off-title',
|
||||
defaultMessage: 'No full backup will be created',
|
||||
},
|
||||
backupOffBody: {
|
||||
id: 'instance.upgrade.confirm.backup.off-body',
|
||||
defaultMessage: 'World changes after launching the upgraded game may not be reversible.',
|
||||
},
|
||||
rollbackTitle: {
|
||||
id: 'instance.upgrade.confirm.rollback.title',
|
||||
defaultMessage: 'Automatic technical rollback',
|
||||
},
|
||||
rollbackDescription: {
|
||||
id: 'instance.upgrade.confirm.rollback.description',
|
||||
defaultMessage:
|
||||
'If file changes fail during the upgrade, the launcher automatically rolls back the operation. This is separate from a full user backup.',
|
||||
},
|
||||
worldTitle: {
|
||||
id: 'instance.upgrade.confirm.world.title',
|
||||
defaultMessage: 'World saves may be migrated irreversibly',
|
||||
},
|
||||
worldBody: {
|
||||
id: 'instance.upgrade.confirm.world.body',
|
||||
defaultMessage:
|
||||
'Launching the upgraded instance may migrate worlds to a newer save format. Opening migrated worlds with an older Minecraft version may be unsafe or unsupported. A full backup is strongly recommended.',
|
||||
},
|
||||
datapackNote: {
|
||||
id: 'instance.upgrade.confirm.world.datapacks',
|
||||
defaultMessage: 'Datapacks inside {path} are preserved but are not automatically upgraded.',
|
||||
},
|
||||
preservedTitle: {
|
||||
id: 'instance.upgrade.confirm.preserved.title',
|
||||
defaultMessage: 'Preserved data',
|
||||
},
|
||||
preservedBody: {
|
||||
id: 'instance.upgrade.confirm.preserved.body',
|
||||
defaultMessage:
|
||||
'Worlds, options, servers, and configuration files are preserved. Mods, resource packs, and shaders follow the selected upgrade solution.',
|
||||
},
|
||||
details: { id: 'instance.upgrade.confirm.details', defaultMessage: 'Change details' },
|
||||
updatedContent: {
|
||||
id: 'instance.upgrade.confirm.details.updated',
|
||||
defaultMessage: 'Updated content',
|
||||
},
|
||||
keptContent: {
|
||||
id: 'instance.upgrade.confirm.details.kept',
|
||||
defaultMessage: 'Kept content',
|
||||
},
|
||||
disabledContent: {
|
||||
id: 'instance.upgrade.confirm.details.disabled',
|
||||
defaultMessage: 'Disabled content',
|
||||
},
|
||||
dependencyChanges: {
|
||||
id: 'instance.upgrade.confirm.details.dependencies',
|
||||
defaultMessage: 'Dependency changes',
|
||||
},
|
||||
providerModrinth: { id: 'instance.upgrade.provider.modrinth', defaultMessage: 'Modrinth' },
|
||||
providerCurseForge: {
|
||||
id: 'instance.upgrade.provider.curseforge',
|
||||
defaultMessage: 'CurseForge',
|
||||
},
|
||||
providerLocal: { id: 'instance.upgrade.provider.local', defaultMessage: 'Local' },
|
||||
providerUnknown: { id: 'instance.upgrade.provider.unknown', defaultMessage: 'Unknown provider' },
|
||||
dependencyFallback: {
|
||||
id: 'instance.upgrade.confirm.details.new-dependency',
|
||||
defaultMessage: 'New {provider} dependency',
|
||||
},
|
||||
contentFallback: {
|
||||
id: 'instance.upgrade.confirm.details.content-item',
|
||||
defaultMessage: 'Content item',
|
||||
},
|
||||
versionTarget: {
|
||||
id: 'instance.upgrade.confirm.details.version-target',
|
||||
defaultMessage: 'Target {version}',
|
||||
},
|
||||
versionCurrent: {
|
||||
id: 'instance.upgrade.confirm.details.version-current',
|
||||
defaultMessage: 'Current {version}',
|
||||
},
|
||||
disabledState: {
|
||||
id: 'instance.upgrade.confirm.details.disabled-state',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
back: { id: 'instance.upgrade.confirm.back', defaultMessage: 'Back' },
|
||||
start: { id: 'instance.upgrade.confirm.start', defaultMessage: 'Start upgrade' },
|
||||
starting: { id: 'instance.upgrade.confirm.starting', defaultMessage: 'Starting upgrade…' },
|
||||
startError: {
|
||||
id: 'instance.upgrade.confirm.start-error',
|
||||
defaultMessage: 'Unable to start upgrade',
|
||||
},
|
||||
activeJob: {
|
||||
id: 'instance.upgrade.confirm.active-job',
|
||||
defaultMessage: 'An upgrade is already in progress',
|
||||
},
|
||||
backupInstanceName: {
|
||||
id: 'instance.upgrade.confirm.backup.instance-name',
|
||||
defaultMessage: '{name} (Pre-upgrade backup)',
|
||||
},
|
||||
copyInstanceName: {
|
||||
id: 'instance.upgrade.confirm.copy.instance-name',
|
||||
defaultMessage: '{name} (Upgraded copy)',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const datapackPath = 'saves/<world>/datapacks'
|
||||
const submissionLock = ref(false)
|
||||
const executionError = ref<string | null>(null)
|
||||
const plan = computed(() => flow.plan.value!)
|
||||
const solution = computed(() => plan.value.selectedSolution!)
|
||||
const sharedInstance = computed(() => isSharedUpgradeInstance(flow.instance.value))
|
||||
const confirmOptions = computed(() =>
|
||||
confirmUpgradeOptions(
|
||||
sharedInstance.value,
|
||||
flow.sharedUpgradeMode.value,
|
||||
flow.directFullBackupPreference.value,
|
||||
),
|
||||
)
|
||||
const effectiveMode = computed(() => confirmOptions.value.effectiveMode)
|
||||
const routeInstanceId = computed(() =>
|
||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
|
||||
)
|
||||
const submissionBusy = computed(() => flow.busy.value || submissionLock.value)
|
||||
const canStartUpgrade = computed(
|
||||
() =>
|
||||
flow.plan.value !== null &&
|
||||
flow.plan.value.blockingIssues.length === 0 &&
|
||||
flow.plan.value.selectedSolution !== null &&
|
||||
!flow.busy.value &&
|
||||
!submissionLock.value &&
|
||||
flow.activeJobId.value === null &&
|
||||
routeInstanceId.value === flow.instanceId.value &&
|
||||
confirmOptions.value.canStart,
|
||||
)
|
||||
const summary = computed(() => solutionSummary(solution.value))
|
||||
const metrics = computed(() => [
|
||||
{ label: formatMessage(messages.updated), value: summary.value.upgraded },
|
||||
{ label: formatMessage(messages.kept), value: summary.value.kept },
|
||||
{ label: formatMessage(messages.disabled), value: summary.value.disabled },
|
||||
{ label: formatMessage(messages.added), value: summary.value.dependencyAdditions },
|
||||
{ label: formatMessage(messages.dependencyUpdated), value: summary.value.dependencyUpdates },
|
||||
{ label: formatMessage(messages.removed), value: summary.value.dependencyRemovals },
|
||||
])
|
||||
const sourceLoader = computed(() =>
|
||||
loaderLabel(
|
||||
plan.value.sourceEnvironment.modLoader,
|
||||
plan.value.sourceEnvironment.modLoaderVersion,
|
||||
),
|
||||
)
|
||||
const targetLoader = computed(() =>
|
||||
confirmTargetLoaderLabel(
|
||||
formatLoaderLabel(plan.value.targetEnvironment.modLoader),
|
||||
plan.value.targetEnvironment.modLoader,
|
||||
plan.value.targetEnvironment.modLoaderVersion,
|
||||
formatMessage(messages.automatic),
|
||||
),
|
||||
)
|
||||
const displayNames = computed(() => {
|
||||
const instance = flow.instance.value
|
||||
if (!instance) return { backup: null, copy: null, upgradedTarget: null, shouldAutoRename: false }
|
||||
return buildUpgradeDisplayNames({
|
||||
sourceName: instance.name,
|
||||
sourceLoader: instance.loader,
|
||||
sourceGameVersion: instance.game_version,
|
||||
sourceLoaderVersion: instance.loader_version ?? null,
|
||||
targetLoader: plan.value.targetEnvironment.modLoader,
|
||||
targetGameVersion: plan.value.targetEnvironment.gameVersion,
|
||||
targetLoaderVersion: plan.value.targetEnvironment.modLoaderVersion,
|
||||
backupName: formatMessage(messages.backupInstanceName, { name: instance.name }),
|
||||
customCopyName: formatMessage(messages.copyInstanceName, { name: instance.name }),
|
||||
})
|
||||
})
|
||||
const strategyLabel = computed(
|
||||
() =>
|
||||
({
|
||||
newest: formatMessage(messages.newest),
|
||||
minimal_change: formatMessage(messages.minimal),
|
||||
custom: formatMessage(messages.custom),
|
||||
})[solution.value.kind],
|
||||
)
|
||||
|
||||
const contentDataQuery = useQuery({
|
||||
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
|
||||
queryFn: () => loadInstanceContentData(flow.instanceId.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const snapshotByContentId = computed(() => {
|
||||
const entries = (contentDataQuery.data.value?.snapshot.items ?? []).flatMap((item) =>
|
||||
contentIdentityKeys({
|
||||
instanceEntryId: item.entryId,
|
||||
instanceMemberId: item.memberId,
|
||||
instanceFileId: item.fileId,
|
||||
relativePath: item.expectedRelativePath,
|
||||
}).map((key) => [key, item] as const),
|
||||
)
|
||||
return new Map<string, InstanceContentSnapshotItem>(entries)
|
||||
})
|
||||
const contentByContentId = computed(() => {
|
||||
const data = contentDataQuery.data.value as InstanceContentData | null | undefined
|
||||
return new Map(
|
||||
[...(data?.contentItems ?? []), ...(data?.linkedContentItems ?? [])].flatMap((item) =>
|
||||
contentIdentityKeys(item).map((key) => [key, item] as const),
|
||||
),
|
||||
)
|
||||
})
|
||||
const itemByContentId = computed(
|
||||
() => new Map(plan.value.items.map((item) => [item.contentId, item])),
|
||||
)
|
||||
const itemByProviderProject = computed(
|
||||
() =>
|
||||
new Map(
|
||||
plan.value.items.flatMap((item) =>
|
||||
item.provider && item.projectId
|
||||
? [[`${item.provider}:${item.projectId}`, item] as const]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const releaseIdentities = computed(() => {
|
||||
const identities: UpgradeReleaseIdentity[] = []
|
||||
const add = (
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null,
|
||||
) => {
|
||||
if ((provider === 'modrinth' || provider === 'curseforge') && projectId && releaseId) {
|
||||
identities.push({ provider, projectId, releaseId })
|
||||
}
|
||||
}
|
||||
for (const selection of solution.value.selections) {
|
||||
add(selection.provider, selection.projectId, selection.currentReleaseId)
|
||||
add(selection.provider, selection.projectId, selection.targetReleaseId)
|
||||
}
|
||||
for (const change of solution.value.dependencyChanges) {
|
||||
add(change.provider, change.projectId, change.currentReleaseId)
|
||||
add(change.provider, change.projectId, change.targetReleaseId)
|
||||
}
|
||||
return identities
|
||||
})
|
||||
const versionMetadataQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'version-display',
|
||||
...releaseIdentities.value.map(
|
||||
(identity) => `${identity.provider}:${identity.projectId}:${identity.releaseId}`,
|
||||
),
|
||||
]),
|
||||
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const projectIdentities = computed(() => {
|
||||
const identities: UpgradeProjectIdentity[] = []
|
||||
const add = (provider: ContentProvider | null, projectId: string | null) => {
|
||||
if ((provider === 'modrinth' || provider === 'curseforge') && projectId) {
|
||||
identities.push({ provider, projectId })
|
||||
}
|
||||
}
|
||||
for (const selection of solution.value.selections) add(selection.provider, selection.projectId)
|
||||
for (const change of solution.value.dependencyChanges) add(change.provider, change.projectId)
|
||||
return identities
|
||||
})
|
||||
const projectMetadataQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'project-display',
|
||||
...projectIdentities.value.map((identity) => `${identity.provider}:${identity.projectId}`),
|
||||
]),
|
||||
queryFn: () => loadUpgradeProjectDisplayMetadata(projectIdentities.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
|
||||
interface ConfirmDetailRow {
|
||||
key: string
|
||||
title: string
|
||||
icon: string
|
||||
provider: ContentProvider | null
|
||||
providerLabel: string
|
||||
projectId: string | null
|
||||
projectPath: string | null
|
||||
currentReleaseId: string | null
|
||||
currentLabel: string | null
|
||||
targetReleaseId: string | null
|
||||
targetLabel: string | null
|
||||
stateLabel: string | null
|
||||
}
|
||||
|
||||
const groupedChanges = computed(() => confirmSolutionGroups(solution.value))
|
||||
const detailGroups = computed(() => [
|
||||
{
|
||||
label: formatMessage(messages.updatedContent),
|
||||
items: groupedChanges.value.updated.map(selectionDetail),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.keptContent),
|
||||
items: groupedChanges.value.kept.map(selectionDetail),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.disabledContent),
|
||||
items: groupedChanges.value.disabled.map(selectionDetail),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.dependencyChanges),
|
||||
items: groupedChanges.value.dependencyChanges.map(dependencyDetail),
|
||||
},
|
||||
])
|
||||
|
||||
function loaderLabel(
|
||||
loader: typeof plan.value.sourceEnvironment.modLoader,
|
||||
version: string | null,
|
||||
) {
|
||||
const label = formatLoaderLabel(loader)
|
||||
return version ? `${label} ${version}` : label
|
||||
}
|
||||
|
||||
function contentMetadata(item: InstanceUpgradePlanItem) {
|
||||
return upgradeContentDisplayMetadata(
|
||||
item,
|
||||
contentByContentId.value.get(item.contentId) ??
|
||||
contentByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
snapshotByContentId.value.get(item.contentId) ??
|
||||
snapshotByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
)
|
||||
}
|
||||
|
||||
function providerLabel(provider: ContentProvider | null): string {
|
||||
if (provider === 'modrinth') return formatMessage(messages.providerModrinth)
|
||||
if (provider === 'curseforge') return formatMessage(messages.providerCurseForge)
|
||||
if (provider === 'local') return formatMessage(messages.providerLocal)
|
||||
return formatMessage(messages.providerUnknown)
|
||||
}
|
||||
|
||||
function releaseLabel(
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null,
|
||||
fallback?: string | null,
|
||||
): string | null {
|
||||
if (!releaseId) return fallback ?? null
|
||||
if (!provider || !projectId) return fallback ?? releaseId
|
||||
const resolved = upgradeVersionDisplayLabel(versionMetadataQuery.data.value, {
|
||||
provider,
|
||||
projectId,
|
||||
releaseId,
|
||||
})
|
||||
return resolved === releaseId && fallback ? fallback : resolved
|
||||
}
|
||||
|
||||
function projectMetadata(provider: ContentProvider | null, projectId: string | null) {
|
||||
if (!provider || !projectId) return null
|
||||
return (
|
||||
projectMetadataQuery.data.value?.get(upgradeProjectDisplayCacheKey(provider, projectId)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function selectionDetail(selection: InstanceUpgradeSelection): ConfirmDetailRow {
|
||||
const item = itemByContentId.value.get(selection.contentId)
|
||||
const metadata = item ? contentMetadata(item) : null
|
||||
const providerMetadata = projectMetadata(selection.provider, selection.projectId)
|
||||
const releases = confirmSelectionReleaseSlots(selection)
|
||||
const current = releaseLabel(
|
||||
selection.provider,
|
||||
selection.projectId,
|
||||
releases.currentReleaseId,
|
||||
metadata?.currentVersion,
|
||||
)
|
||||
const target = releaseLabel(selection.provider, selection.projectId, releases.targetReleaseId)
|
||||
return {
|
||||
key: selection.contentId,
|
||||
title: metadata?.title ?? providerMetadata?.title ?? formatMessage(messages.contentFallback),
|
||||
icon: localContentIconUrl(metadata?.iconUrl ?? providerMetadata?.iconUrl),
|
||||
provider: selection.provider,
|
||||
providerLabel: providerLabel(selection.provider),
|
||||
projectId: selection.projectId,
|
||||
projectPath: upgradeProjectPath(selection.provider, selection.projectId),
|
||||
currentReleaseId: releases.currentReleaseId,
|
||||
currentLabel: current ? formatMessage(messages.versionCurrent, { version: current }) : null,
|
||||
targetReleaseId: releases.targetReleaseId,
|
||||
targetLabel: target ? formatMessage(messages.versionTarget, { version: target }) : null,
|
||||
stateLabel: selection.action === 'disable' ? formatMessage(messages.disabledState) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyDetail(change: InstanceUpgradeDependencyChange): ConfirmDetailRow {
|
||||
const item =
|
||||
(change.existingContentId ? itemByContentId.value.get(change.existingContentId) : null) ??
|
||||
itemByProviderProject.value.get(`${change.provider}:${change.projectId}`)
|
||||
const metadata = item ? contentMetadata(item) : null
|
||||
const providerMetadata = projectMetadata(change.provider, change.projectId)
|
||||
const releases = resolveConfirmDependencyReleases(change, (releaseId, slot) =>
|
||||
releaseLabel(
|
||||
change.provider,
|
||||
change.projectId,
|
||||
releaseId,
|
||||
slot === 'current' ? metadata?.currentVersion : undefined,
|
||||
),
|
||||
)
|
||||
return {
|
||||
key: `${change.provider}:${change.projectId}:${change.existingContentId ?? 'new'}:${change.kind}`,
|
||||
title:
|
||||
metadata?.title ??
|
||||
providerMetadata?.title ??
|
||||
formatMessage(messages.dependencyFallback, { provider: providerLabel(change.provider) }),
|
||||
icon: localContentIconUrl(metadata?.iconUrl ?? providerMetadata?.iconUrl),
|
||||
provider: change.provider,
|
||||
providerLabel: providerLabel(change.provider),
|
||||
projectId: change.projectId,
|
||||
projectPath: upgradeProjectPath(change.provider, change.projectId),
|
||||
currentReleaseId: releases.currentReleaseId,
|
||||
currentLabel: releases.current
|
||||
? formatMessage(messages.versionCurrent, { version: releases.current })
|
||||
: null,
|
||||
targetReleaseId: releases.targetReleaseId,
|
||||
targetLabel: releases.target
|
||||
? formatMessage(messages.versionTarget, { version: releases.target })
|
||||
: null,
|
||||
stateLabel: null,
|
||||
}
|
||||
}
|
||||
|
||||
function parkProjectReturn() {
|
||||
parkUpgradeFlow({
|
||||
instanceId: flow.instanceId.value,
|
||||
returnFullPath: router.currentRoute.value.fullPath,
|
||||
targetEnvironment: flow.targetEnvironment.value,
|
||||
plan: flow.plan.value,
|
||||
createFullBackup: flow.createFullBackup.value,
|
||||
directFullBackupPreference: flow.directFullBackupPreference.value,
|
||||
sharedUpgradeMode: flow.sharedUpgradeMode.value,
|
||||
activeJobId: flow.activeJobId.value,
|
||||
result: flow.result.value,
|
||||
initialBlockingPlanId: flow.initialBlockingPlanId.value,
|
||||
initialBlockingIssues: flow.initialBlockingIssues.value,
|
||||
customizeActiveStrategy: flow.customizeActiveStrategy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function selectMode(mode: SharedUpgradeMode) {
|
||||
const previousMode = flow.sharedUpgradeMode.value
|
||||
flow.sharedUpgradeMode.value = mode
|
||||
if (mode === 'copy_and_upgrade') {
|
||||
if (previousMode !== 'copy_and_upgrade') {
|
||||
flow.directFullBackupPreference.value = flow.createFullBackup.value
|
||||
}
|
||||
flow.createFullBackup.value = false
|
||||
} else {
|
||||
flow.createFullBackup.value = flow.directFullBackupPreference.value
|
||||
}
|
||||
}
|
||||
|
||||
function rememberBackupPreference(value: boolean) {
|
||||
flow.directFullBackupPreference.value = value
|
||||
}
|
||||
|
||||
function modeClass(mode: SharedUpgradeMode) {
|
||||
return flow.sharedUpgradeMode.value === mode
|
||||
? 'border-brand bg-surface-2 ring-1 ring-brand'
|
||||
: 'border-surface-4 bg-surface-2 hover:bg-surface-3'
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function startUpgrade() {
|
||||
if (!canStartUpgrade.value || !flow.plan.value || !effectiveMode.value) return
|
||||
flow.busy.value = true
|
||||
executionError.value = null
|
||||
try {
|
||||
const submitted = await submitInstanceUpgrade(
|
||||
{
|
||||
instanceId: flow.instanceId.value,
|
||||
planId: flow.plan.value.id,
|
||||
createFullBackup: confirmOptions.value.createFullBackup,
|
||||
sharedUpgradeMode: effectiveMode.value,
|
||||
displayNames: displayNames.value,
|
||||
},
|
||||
submissionLock,
|
||||
)
|
||||
if (!submitted) return
|
||||
await router.replace(attachUpgradeJobToFlow(flow, submitted.job))
|
||||
} catch (error) {
|
||||
executionError.value = errorMessage(error)
|
||||
} finally {
|
||||
flow.busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function registerControls() {
|
||||
flow.registerStepControls({
|
||||
canNext: canStartUpgrade,
|
||||
busy: submissionBusy,
|
||||
nextLabel: formatMessage(submissionBusy.value ? messages.starting : messages.start),
|
||||
onNext: startUpgrade,
|
||||
onBack: () =>
|
||||
router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/customize`),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!sharedInstance.value) {
|
||||
flow.sharedUpgradeMode.value = 'direct'
|
||||
flow.createFullBackup.value = confirmOptions.value.createFullBackup
|
||||
}
|
||||
registerControls()
|
||||
})
|
||||
watch([canStartUpgrade, submissionBusy], registerControls)
|
||||
onBeforeUnmount(() => flow.registerStepControls(null))
|
||||
</script>
|
||||
914
apps/app-frontend/src/pages/instance/upgrade/Customize.vue
Normal file
914
apps/app-frontend/src/pages/instance/upgrade/Customize.vue
Normal file
@ -0,0 +1,914 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-0 mt-1 max-w-2xl text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
v-if="availableStrategies.includes('newest') && plan.newestSolution"
|
||||
type="button"
|
||||
class="flex min-h-36 flex-col gap-2 rounded-lg border border-solid p-4 text-left transition-colors"
|
||||
:class="strategyClass('newest')"
|
||||
:disabled="requestBusy"
|
||||
@click="chooseStrategy('newest')"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.newestTitle) }}
|
||||
<CheckIcon
|
||||
v-if="activeStrategy === 'newest'"
|
||||
class="ml-auto text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.newestDescription) }}</p>
|
||||
<span class="mt-auto text-sm text-secondary">{{ summaryText(plan.newestSolution) }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="availableStrategies.includes('minimal_change') && plan.minimalChangeSolution"
|
||||
type="button"
|
||||
class="flex min-h-36 flex-col gap-2 rounded-lg border border-solid p-4 text-left transition-colors"
|
||||
:class="strategyClass('minimal_change')"
|
||||
:disabled="requestBusy"
|
||||
@click="chooseStrategy('minimal_change')"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<MinimizeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.minimalTitle) }}
|
||||
<CheckIcon
|
||||
v-if="activeStrategy === 'minimal_change'"
|
||||
class="ml-auto text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.minimalDescription) }}</p>
|
||||
<span class="mt-auto text-sm text-secondary">{{
|
||||
summaryText(plan.minimalChangeSolution)
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-36 flex-col gap-2 rounded-lg border border-solid p-4 text-left transition-colors"
|
||||
:class="strategyClass('custom')"
|
||||
:disabled="requestBusy"
|
||||
@click="chooseStrategy('custom')"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.customTitle) }}
|
||||
<CheckIcon
|
||||
v-if="activeStrategy === 'custom'"
|
||||
class="ml-auto text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.customDescription) }}</p>
|
||||
<span class="mt-auto text-sm text-secondary">
|
||||
{{ formatMessage(messages.customConstraintCount, { count: draftConstraints.length }) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="pendingStrategy"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unsavedTitle)"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span>{{ formatMessage(messages.unsavedBody) }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button @click="pendingStrategy = null">{{ formatMessage(messages.cancel) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange" size="small">
|
||||
<button @click="discardAndSwitch">
|
||||
{{ formatMessage(messages.discardAndSwitch) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-if="requestError"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.requestErrorTitle)"
|
||||
>
|
||||
{{ requestError }}
|
||||
</Admonition>
|
||||
|
||||
<section v-if="activeStrategy === 'custom'" class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.customChoices) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.customChoicesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canApplyCustom" @click="applyCustomChoices">
|
||||
<SpinnerIcon v-if="requestBusy" class="animate-spin" aria-hidden="true" />
|
||||
<RefreshCwIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(customWasResolved ? messages.recalculate : messages.applyCustom) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="customDraftDirty"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unappliedTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.unappliedBody) }}
|
||||
</Admonition>
|
||||
|
||||
<div class="rounded-lg border border-solid border-surface-4">
|
||||
<article
|
||||
v-for="item in editableRoots"
|
||||
:key="item.contentId"
|
||||
class="relative flex flex-col gap-3 border-0 border-b border-solid border-surface-4 bg-surface-2 p-3 first:rounded-t-lg last:rounded-b-lg last:border-b-0 focus-within:z-20 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar :src="itemIcon(item)" :tint-by="itemName(item)" size="2.5rem" no-shadow />
|
||||
<div class="min-w-0">
|
||||
<RouterLink
|
||||
v-if="projectPath(item)"
|
||||
:to="projectPath(item)!"
|
||||
class="inline-flex max-w-full items-center gap-1 font-semibold text-contrast hover:text-brand hover:underline focus-visible:underline"
|
||||
@click="parkProjectReturn"
|
||||
><span class="truncate">{{ itemName(item) }}</span
|
||||
><ExternalIcon class="size-3 shrink-0" aria-hidden="true"
|
||||
/></RouterLink>
|
||||
<div v-else class="truncate font-semibold text-contrast">{{ itemName(item) }}</div>
|
||||
<div class="flex flex-wrap gap-x-3 text-sm text-secondary">
|
||||
<span>{{ providerLabel(item.provider) }}</span>
|
||||
<span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.currentReleaseId"
|
||||
:label="currentVersionLabel(item)"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.currentReleaseId"
|
||||
/>
|
||||
<span v-else>{{ currentVersionLabel(item) }}</span>
|
||||
</span>
|
||||
<span v-if="!item.currentEnabled">{{
|
||||
formatMessage(messages.currentlyDisabled)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-secondary">
|
||||
<span>{{ formatMessage(messages.effectiveTargetPrefix) }}</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="effectiveTargetRelease(item)"
|
||||
:label="effectiveTargetVersion(item)"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="effectiveTargetRelease(item)"
|
||||
/>
|
||||
<span v-else>{{ formatMessage(messages.noTarget) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 w-full shrink-0 sm:w-72 sm:max-w-[45%]">
|
||||
<label
|
||||
class="mb-1 block text-sm font-medium text-contrast"
|
||||
:for="`custom-${item.contentId}`"
|
||||
>
|
||||
{{ formatMessage(messages.choice) }}
|
||||
</label>
|
||||
<DropdownSelect
|
||||
class="!w-full max-w-full min-w-0"
|
||||
:model-value="draftChoice(item.contentId)"
|
||||
:name="`custom-${item.contentId}`"
|
||||
:options="constraintOptions(item)"
|
||||
:display-name="(value) => constraintOptionLabel(item, String(value))"
|
||||
:disabled="requestBusy"
|
||||
@update:model-value="setDraftChoice(item, String($event))"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="customIssues.length" class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.customIssues) }}
|
||||
</h3>
|
||||
<Admonition
|
||||
v-for="(issue, index) in customIssues"
|
||||
:key="`${issue.code}:${issue.contentId ?? issue.projectId ?? index}`"
|
||||
:type="issue.code === 'search_limit_reached' ? 'warning' : 'critical'"
|
||||
:header="customIssueTitle(issue)"
|
||||
>
|
||||
{{ customIssueBody(issue) }}
|
||||
</Admonition>
|
||||
</section>
|
||||
|
||||
<section v-if="effectiveSolution" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.effectiveChanges) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{
|
||||
activeStrategy === 'custom' && !customWasResolved
|
||||
? formatMessage(messages.baselineHint)
|
||||
: summaryText(effectiveSolution)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-surface-4 sm:grid-cols-3 lg:grid-cols-6"
|
||||
>
|
||||
<div v-for="metric in effectiveMetrics" :key="metric.label" class="bg-surface-2 p-3">
|
||||
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
|
||||
<div class="text-sm text-secondary">{{ metric.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveDependencyChanges.length" class="flex flex-col gap-2">
|
||||
<h4 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.dependenciesTitle) }}
|
||||
</h4>
|
||||
<div class="overflow-hidden rounded-lg border border-solid border-surface-4">
|
||||
<div
|
||||
v-for="change in effectiveDependencyChanges"
|
||||
:key="`${change.provider}:${change.projectId}:${change.existingContentId ?? 'new'}`"
|
||||
class="flex items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-2 p-3 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-semibold text-contrast">{{ change.projectId }}</div>
|
||||
<div class="text-sm text-secondary">
|
||||
{{ dependencyChangeDescription(change) }}
|
||||
</div>
|
||||
</div>
|
||||
<strong class="shrink-0 text-sm text-contrast">{{
|
||||
dependencyActionLabel(change.kind)
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ExternalIcon,
|
||||
MinimizeIcon,
|
||||
RefreshCwIcon,
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { InstanceContentSnapshotItem } from '@/helpers/instance'
|
||||
import {
|
||||
type InstanceContentData,
|
||||
loadInstanceContentData,
|
||||
localContentIconUrl,
|
||||
} from '@/helpers/instance-content'
|
||||
import type {
|
||||
ContentProvider,
|
||||
InstanceUpgradeDependencyChange,
|
||||
InstanceUpgradeDependencyChangeKind,
|
||||
InstanceUpgradeFixedConstraint,
|
||||
InstanceUpgradeIssue,
|
||||
InstanceUpgradePlanItem,
|
||||
InstanceUpgradeSolution,
|
||||
InstanceUpgradeSolutionKind,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import {
|
||||
resolve_custom_instance_upgrade_solution,
|
||||
select_instance_upgrade_solution,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import { parkUpgradeFlow, upgradeProjectPath } from '@/helpers/upgrade-return-state'
|
||||
import {
|
||||
loadUpgradeVersionDisplayMetadata,
|
||||
type UpgradeReleaseIdentity,
|
||||
upgradeVersionCacheKey,
|
||||
upgradeVersionDisplayLabel,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
import {
|
||||
availablePredefinedStrategies,
|
||||
contentIdentityKeys,
|
||||
customConstraintsEqual,
|
||||
editableUpgradeRoots,
|
||||
normalizeUpgradePath,
|
||||
setFixedConstraint,
|
||||
solutionSummary,
|
||||
upgradeContentDisplayMetadata,
|
||||
} from './analysis'
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
import { initialCustomizeStrategy } from './flow-controls'
|
||||
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
|
||||
|
||||
const AUTOMATIC = '__automatic__'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.customize.title', defaultMessage: 'Upgrade strategy' },
|
||||
description: {
|
||||
id: 'instance.upgrade.customize.description',
|
||||
defaultMessage: 'Choose how aggressively Axolotl should update content in this instance.',
|
||||
},
|
||||
newestTitle: { id: 'instance.upgrade.customize.newest.title', defaultMessage: 'Newest versions' },
|
||||
newestDescription: {
|
||||
id: 'instance.upgrade.customize.newest.description',
|
||||
defaultMessage:
|
||||
'Update compatible content to the newest versions available for the target environment.',
|
||||
},
|
||||
minimalTitle: {
|
||||
id: 'instance.upgrade.customize.minimal.title',
|
||||
defaultMessage: 'Minimal changes',
|
||||
},
|
||||
minimalDescription: {
|
||||
id: 'instance.upgrade.customize.minimal.description',
|
||||
defaultMessage:
|
||||
'Keep compatible current versions and change as little installed content as possible.',
|
||||
},
|
||||
customTitle: { id: 'instance.upgrade.customize.custom.title', defaultMessage: 'Custom' },
|
||||
customDescription: {
|
||||
id: 'instance.upgrade.customize.custom.description',
|
||||
defaultMessage:
|
||||
'Fix exact versions for selected content and let Axolotl solve the remaining dependency graph.',
|
||||
},
|
||||
customConstraintCount: {
|
||||
id: 'instance.upgrade.customize.custom.constraint-count',
|
||||
defaultMessage: '{count, plural, one {# exact choice} other {# exact choices}}',
|
||||
},
|
||||
rootSummary: {
|
||||
id: 'instance.upgrade.customize.summary.roots',
|
||||
defaultMessage:
|
||||
'{updates} updates, {kept} kept, {disabled} disabled, {dependencies, plural, one {# dependency change} other {# dependency changes}}',
|
||||
},
|
||||
customChoices: {
|
||||
id: 'instance.upgrade.customize.custom.choices',
|
||||
defaultMessage: 'Custom choices',
|
||||
},
|
||||
customChoicesDescription: {
|
||||
id: 'instance.upgrade.customize.custom.choices-description',
|
||||
defaultMessage: 'Only user-owned root content is editable. Dependencies remain solver-managed.',
|
||||
},
|
||||
choice: { id: 'instance.upgrade.customize.custom.choice', defaultMessage: 'Target version' },
|
||||
automatic: { id: 'instance.upgrade.customize.custom.automatic', defaultMessage: 'Automatic' },
|
||||
specificVersion: {
|
||||
id: 'instance.upgrade.customize.custom.specific-version',
|
||||
defaultMessage: 'Exact release {version}',
|
||||
},
|
||||
specificVersionWithChannel: {
|
||||
id: 'instance.upgrade.customize.custom.specific-version-channel',
|
||||
defaultMessage: '{version} ({channel})',
|
||||
},
|
||||
channelRelease: { id: 'instance.upgrade.customize.channel.release', defaultMessage: 'Release' },
|
||||
channelBeta: { id: 'instance.upgrade.customize.channel.beta', defaultMessage: 'Beta' },
|
||||
channelAlpha: { id: 'instance.upgrade.customize.channel.alpha', defaultMessage: 'Alpha' },
|
||||
applyCustom: {
|
||||
id: 'instance.upgrade.customize.custom.apply',
|
||||
defaultMessage: 'Apply custom choices',
|
||||
},
|
||||
recalculate: {
|
||||
id: 'instance.upgrade.customize.custom.recalculate',
|
||||
defaultMessage: 'Recalculate',
|
||||
},
|
||||
unappliedTitle: {
|
||||
id: 'instance.upgrade.customize.custom.unapplied-title',
|
||||
defaultMessage: 'Custom choices have not been applied',
|
||||
},
|
||||
unappliedBody: {
|
||||
id: 'instance.upgrade.customize.custom.unapplied-body',
|
||||
defaultMessage: 'Apply these choices to calculate a globally compatible solution.',
|
||||
},
|
||||
unsavedTitle: {
|
||||
id: 'instance.upgrade.customize.custom.unsaved-title',
|
||||
defaultMessage: 'Discard unapplied custom choices?',
|
||||
},
|
||||
unsavedBody: {
|
||||
id: 'instance.upgrade.customize.custom.unsaved-body',
|
||||
defaultMessage: 'Switching strategy will discard changes that have not been calculated.',
|
||||
},
|
||||
discardAndSwitch: {
|
||||
id: 'instance.upgrade.customize.custom.discard-switch',
|
||||
defaultMessage: 'Discard and switch',
|
||||
},
|
||||
cancel: { id: 'instance.upgrade.customize.cancel', defaultMessage: 'Cancel' },
|
||||
requestErrorTitle: {
|
||||
id: 'instance.upgrade.customize.request-error-title',
|
||||
defaultMessage: 'Strategy could not be updated',
|
||||
},
|
||||
customIssues: {
|
||||
id: 'instance.upgrade.customize.custom.issues',
|
||||
defaultMessage: 'Unable to resolve custom choices',
|
||||
},
|
||||
searchLimitTitle: {
|
||||
id: 'instance.upgrade.customize.search-limit.title',
|
||||
defaultMessage: 'Search limit reached',
|
||||
},
|
||||
searchLimitBody: {
|
||||
id: 'instance.upgrade.customize.search-limit.body',
|
||||
defaultMessage:
|
||||
"Axolotl couldn't find a solution within the search limit. Try relaxing one of your custom choices.",
|
||||
},
|
||||
conflictTitle: {
|
||||
id: 'instance.upgrade.customize.conflict.title',
|
||||
defaultMessage: 'Custom choices conflict',
|
||||
},
|
||||
effectiveChanges: {
|
||||
id: 'instance.upgrade.customize.effective-changes',
|
||||
defaultMessage: 'Effective changes',
|
||||
},
|
||||
dependenciesTitle: {
|
||||
id: 'instance.upgrade.customize.dependencies-title',
|
||||
defaultMessage: 'Dependency changes',
|
||||
},
|
||||
baselineHint: {
|
||||
id: 'instance.upgrade.customize.baseline-hint',
|
||||
defaultMessage:
|
||||
'Current selected solution shown as the baseline. Apply custom choices to recalculate it.',
|
||||
},
|
||||
metricUpdated: {
|
||||
id: 'instance.upgrade.customize.metric.updated',
|
||||
defaultMessage: 'Content updated',
|
||||
},
|
||||
metricKept: { id: 'instance.upgrade.customize.metric.kept', defaultMessage: 'Kept' },
|
||||
metricDisabled: { id: 'instance.upgrade.customize.metric.disabled', defaultMessage: 'Disabled' },
|
||||
metricAdded: {
|
||||
id: 'instance.upgrade.customize.metric.added',
|
||||
defaultMessage: 'Dependencies added',
|
||||
},
|
||||
metricDependencyUpdated: {
|
||||
id: 'instance.upgrade.customize.metric.dependency-updated',
|
||||
defaultMessage: 'Dependencies updated',
|
||||
},
|
||||
metricRemoved: {
|
||||
id: 'instance.upgrade.customize.metric.removed',
|
||||
defaultMessage: 'Dependencies removed',
|
||||
},
|
||||
currentVersion: {
|
||||
id: 'instance.upgrade.customize.current-version',
|
||||
defaultMessage: 'Current: {version}',
|
||||
},
|
||||
effectiveTarget: {
|
||||
id: 'instance.upgrade.customize.effective-target',
|
||||
defaultMessage: 'Calculated target: {version}',
|
||||
},
|
||||
effectiveTargetPrefix: {
|
||||
id: 'instance.upgrade.customize.effective-target-prefix',
|
||||
defaultMessage: 'Calculated target: ',
|
||||
},
|
||||
noTarget: { id: 'instance.upgrade.customize.no-target', defaultMessage: 'No target release' },
|
||||
currentlyDisabled: {
|
||||
id: 'instance.upgrade.customize.currently-disabled',
|
||||
defaultMessage: 'Currently disabled',
|
||||
},
|
||||
providerModrinth: { id: 'instance.upgrade.provider.modrinth', defaultMessage: 'Modrinth' },
|
||||
providerCurseForge: { id: 'instance.upgrade.provider.curseforge', defaultMessage: 'CurseForge' },
|
||||
providerUnknown: { id: 'instance.upgrade.provider.unknown', defaultMessage: 'Unknown provider' },
|
||||
dependencyAdd: {
|
||||
id: 'instance.upgrade.customize.dependency.add',
|
||||
defaultMessage: 'Add dependency',
|
||||
},
|
||||
dependencyUpgrade: {
|
||||
id: 'instance.upgrade.customize.dependency.upgrade',
|
||||
defaultMessage: 'Update dependency',
|
||||
},
|
||||
dependencyRemove: {
|
||||
id: 'instance.upgrade.customize.dependency.remove',
|
||||
defaultMessage: 'Remove dependency',
|
||||
},
|
||||
dependencyKeep: {
|
||||
id: 'instance.upgrade.customize.dependency.keep',
|
||||
defaultMessage: 'Keep dependency',
|
||||
},
|
||||
dependencyReused: {
|
||||
id: 'instance.upgrade.customize.dependency.reused',
|
||||
defaultMessage: 'Reuses existing content: {current} to {target}',
|
||||
},
|
||||
dependencyNew: {
|
||||
id: 'instance.upgrade.customize.dependency.new',
|
||||
defaultMessage: 'New content: {target}',
|
||||
},
|
||||
back: { id: 'instance.upgrade.customize.back', defaultMessage: 'Back' },
|
||||
continue: { id: 'instance.upgrade.customize.continue', defaultMessage: 'Continue' },
|
||||
applyBeforeContinue: {
|
||||
id: 'instance.upgrade.customize.apply-before-continue',
|
||||
defaultMessage: 'Apply your custom choices first.',
|
||||
},
|
||||
resolveBeforeContinue: {
|
||||
id: 'instance.upgrade.customize.resolve-before-continue',
|
||||
defaultMessage: 'Resolve custom conflicts before continuing.',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const plan = computed(() => flow.plan.value!)
|
||||
const activeStrategy = ref<InstanceUpgradeSolutionKind>(
|
||||
initialCustomizeStrategy(
|
||||
flow.customizeActiveStrategy.value,
|
||||
plan.value.selectedSolution?.kind,
|
||||
'custom',
|
||||
),
|
||||
)
|
||||
flow.customizeActiveStrategy.value = activeStrategy.value
|
||||
const draftConstraints = ref<InstanceUpgradeFixedConstraint[]>(
|
||||
plan.value.customConstraints.map((item) => ({ ...item })),
|
||||
)
|
||||
const pendingStrategy = ref<Exclude<InstanceUpgradeSolutionKind, 'custom'> | null>(null)
|
||||
const requestBusy = ref(false)
|
||||
const requestError = ref<string | null>(null)
|
||||
|
||||
const contentDataQuery = useQuery({
|
||||
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
|
||||
queryFn: () => loadInstanceContentData(flow.instanceId.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const releaseIdentities = computed(() => {
|
||||
const identities: UpgradeReleaseIdentity[] = []
|
||||
const add = (
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null,
|
||||
) => {
|
||||
if ((provider === 'modrinth' || provider === 'curseforge') && projectId && releaseId) {
|
||||
identities.push({ provider, projectId, releaseId })
|
||||
}
|
||||
}
|
||||
for (const item of plan.value.items) {
|
||||
add(item.provider, item.projectId, item.currentReleaseId)
|
||||
item.candidateReleaseIds.forEach((releaseId) => add(item.provider, item.projectId, releaseId))
|
||||
}
|
||||
for (const selection of plan.value.selectedSolution?.selections ?? []) {
|
||||
add(selection.provider, selection.projectId, selection.targetReleaseId)
|
||||
}
|
||||
for (const change of plan.value.selectedSolution?.dependencyChanges ?? []) {
|
||||
add(change.provider, change.projectId, change.currentReleaseId)
|
||||
add(change.provider, change.projectId, change.targetReleaseId)
|
||||
}
|
||||
return identities
|
||||
})
|
||||
const versionMetadataQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'version-display',
|
||||
...releaseIdentities.value.map((identity) =>
|
||||
upgradeVersionCacheKey(identity.provider, identity.projectId, identity.releaseId),
|
||||
),
|
||||
]),
|
||||
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const snapshotByContentId = computed(() => {
|
||||
const entries = (contentDataQuery.data.value?.snapshot.items ?? []).flatMap((item) =>
|
||||
contentIdentityKeys({
|
||||
instanceEntryId: item.entryId,
|
||||
instanceMemberId: item.memberId,
|
||||
instanceFileId: item.fileId,
|
||||
relativePath: item.expectedRelativePath,
|
||||
}).map((key) => [key, item] as const),
|
||||
)
|
||||
return new Map<string, InstanceContentSnapshotItem>(entries)
|
||||
})
|
||||
const contentByContentId = computed(() => {
|
||||
const data = contentDataQuery.data.value as InstanceContentData | null | undefined
|
||||
return new Map(
|
||||
[...(data?.contentItems ?? []), ...(data?.linkedContentItems ?? [])].flatMap((item) =>
|
||||
contentIdentityKeys(item).map((key) => [key, item] as const),
|
||||
),
|
||||
)
|
||||
})
|
||||
const editableRoots = computed(() => editableUpgradeRoots(plan.value))
|
||||
const availableStrategies = computed(() => availablePredefinedStrategies(plan.value))
|
||||
const customDraftDirty = computed(
|
||||
() => !customConstraintsEqual(draftConstraints.value, plan.value.customConstraints),
|
||||
)
|
||||
const customWasResolved = computed(() => plan.value.selectedSolution?.kind === 'custom')
|
||||
const effectiveSolution = computed(() => plan.value.selectedSolution)
|
||||
const effectiveDependencyChanges = computed(() =>
|
||||
(effectiveSolution.value?.dependencyChanges ?? []).filter((entry) => entry.kind !== 'keep'),
|
||||
)
|
||||
const effectiveSummary = computed(() =>
|
||||
effectiveSolution.value ? solutionSummary(effectiveSolution.value) : null,
|
||||
)
|
||||
const effectiveMetrics = computed(() => {
|
||||
const summary = effectiveSummary.value
|
||||
if (!summary) return []
|
||||
return [
|
||||
{ label: formatMessage(messages.metricUpdated), value: summary.upgraded },
|
||||
{ label: formatMessage(messages.metricKept), value: summary.kept },
|
||||
{ label: formatMessage(messages.metricDisabled), value: summary.disabled },
|
||||
{ label: formatMessage(messages.metricAdded), value: summary.dependencyAdditions },
|
||||
{ label: formatMessage(messages.metricDependencyUpdated), value: summary.dependencyUpdates },
|
||||
{ label: formatMessage(messages.metricRemoved), value: summary.dependencyRemovals },
|
||||
]
|
||||
})
|
||||
const customIssues = computed(() =>
|
||||
activeStrategy.value === 'custom' && !customDraftDirty.value ? plan.value.blockingIssues : [],
|
||||
)
|
||||
const canApplyCustom = computed(
|
||||
() =>
|
||||
activeStrategy.value === 'custom' &&
|
||||
!requestBusy.value &&
|
||||
(customDraftDirty.value || !customWasResolved.value),
|
||||
)
|
||||
const canContinue = computed(
|
||||
() =>
|
||||
!requestBusy.value &&
|
||||
!customDraftDirty.value &&
|
||||
plan.value.blockingIssues.length === 0 &&
|
||||
plan.value.selectedSolution !== null &&
|
||||
plan.value.selectedSolution.kind === activeStrategy.value,
|
||||
)
|
||||
function registerControls() {
|
||||
flow.registerStepControls({
|
||||
canNext: canContinue,
|
||||
busy: requestBusy,
|
||||
nextLabel: formatMessage(messages.continue),
|
||||
onNext: continueUpgrade,
|
||||
onBack: goBack,
|
||||
})
|
||||
}
|
||||
onMounted(registerControls)
|
||||
watch([canContinue, requestBusy], registerControls)
|
||||
onBeforeUnmount(() => flow.registerStepControls(null))
|
||||
|
||||
function strategyClass(kind: InstanceUpgradeSolutionKind) {
|
||||
return activeStrategy.value === kind
|
||||
? 'border-brand bg-surface-2 ring-1 ring-brand'
|
||||
: 'border-surface-4 bg-surface-2 hover:bg-surface-3'
|
||||
}
|
||||
|
||||
function summaryText(solution: InstanceUpgradeSolution): string {
|
||||
const summary = solutionSummary(solution)
|
||||
return formatMessage(messages.rootSummary, {
|
||||
updates: summary.upgraded,
|
||||
kept: summary.kept,
|
||||
disabled: summary.disabled,
|
||||
dependencies:
|
||||
summary.dependencyAdditions + summary.dependencyUpdates + summary.dependencyRemovals,
|
||||
})
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function chooseStrategy(kind: InstanceUpgradeSolutionKind) {
|
||||
if (requestBusy.value || kind === activeStrategy.value) return
|
||||
requestError.value = null
|
||||
if (kind === 'custom') {
|
||||
activeStrategy.value = 'custom'
|
||||
flow.customizeActiveStrategy.value = 'custom'
|
||||
return
|
||||
}
|
||||
if (customDraftDirty.value) {
|
||||
pendingStrategy.value = kind
|
||||
return
|
||||
}
|
||||
activeStrategy.value = kind
|
||||
flow.customizeActiveStrategy.value = kind
|
||||
await selectPredefined(kind)
|
||||
}
|
||||
|
||||
async function discardAndSwitch() {
|
||||
const target = pendingStrategy.value
|
||||
if (!target) return
|
||||
draftConstraints.value = plan.value.customConstraints.map((item) => ({ ...item }))
|
||||
pendingStrategy.value = null
|
||||
await selectPredefined(target)
|
||||
}
|
||||
|
||||
async function selectPredefined(kind: Exclude<InstanceUpgradeSolutionKind, 'custom'>) {
|
||||
if (plan.value.selectedSolution?.kind === kind) {
|
||||
activeStrategy.value = kind
|
||||
flow.customizeActiveStrategy.value = kind
|
||||
return
|
||||
}
|
||||
requestBusy.value = true
|
||||
try {
|
||||
const updatedPlan = await select_instance_upgrade_solution(plan.value.id, kind)
|
||||
flow.setPlan(updatedPlan)
|
||||
activeStrategy.value = kind
|
||||
flow.customizeActiveStrategy.value = kind
|
||||
} catch (error) {
|
||||
requestError.value = errorMessage(error)
|
||||
} finally {
|
||||
requestBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function contentMetadata(item: InstanceUpgradePlanItem) {
|
||||
return upgradeContentDisplayMetadata(
|
||||
item,
|
||||
contentByContentId.value.get(item.contentId) ??
|
||||
contentByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
snapshotByContentId.value.get(item.contentId) ??
|
||||
snapshotByContentId.value.get(normalizeUpgradePath(item.relativePath)),
|
||||
)
|
||||
}
|
||||
|
||||
function itemName(item: InstanceUpgradePlanItem): string {
|
||||
return contentMetadata(item).title
|
||||
}
|
||||
|
||||
function projectPath(item: InstanceUpgradePlanItem): string | null {
|
||||
return upgradeProjectPath(item.provider, item.projectId)
|
||||
}
|
||||
|
||||
function parkProjectReturn() {
|
||||
parkUpgradeFlow({
|
||||
instanceId: flow.instanceId.value,
|
||||
returnFullPath: router.currentRoute.value.fullPath,
|
||||
targetEnvironment: flow.targetEnvironment.value,
|
||||
plan: flow.plan.value,
|
||||
createFullBackup: flow.createFullBackup.value,
|
||||
directFullBackupPreference: flow.directFullBackupPreference.value,
|
||||
sharedUpgradeMode: flow.sharedUpgradeMode.value,
|
||||
activeJobId: flow.activeJobId.value,
|
||||
result: flow.result.value,
|
||||
initialBlockingPlanId: flow.initialBlockingPlanId.value,
|
||||
initialBlockingIssues: flow.initialBlockingIssues.value,
|
||||
customizeActiveStrategy: flow.customizeActiveStrategy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function itemIcon(item: InstanceUpgradePlanItem): string {
|
||||
return localContentIconUrl(contentMetadata(item).iconUrl)
|
||||
}
|
||||
|
||||
function providerLabel(provider: ContentProvider | null): string {
|
||||
if (provider === 'modrinth') return formatMessage(messages.providerModrinth)
|
||||
if (provider === 'curseforge') return formatMessage(messages.providerCurseForge)
|
||||
return formatMessage(messages.providerUnknown)
|
||||
}
|
||||
|
||||
function currentVersionLabel(item: InstanceUpgradePlanItem): string {
|
||||
const version = releaseLabel(
|
||||
item.provider,
|
||||
item.projectId,
|
||||
item.currentReleaseId,
|
||||
contentMetadata(item).currentVersion,
|
||||
)
|
||||
return formatMessage(messages.currentVersion, { version })
|
||||
}
|
||||
|
||||
function effectiveTargetRelease(item: InstanceUpgradePlanItem): string | null {
|
||||
return (
|
||||
effectiveSolution.value?.selections.find((entry) => entry.contentId === item.contentId)
|
||||
?.targetReleaseId ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function effectiveTargetVersion(item: InstanceUpgradePlanItem): string {
|
||||
return releaseLabel(item.provider, item.projectId, effectiveTargetRelease(item))
|
||||
}
|
||||
|
||||
function releaseLabel(
|
||||
provider: ContentProvider | null,
|
||||
projectId: string | null,
|
||||
releaseId: string | null | undefined,
|
||||
fallback?: string | null,
|
||||
): string {
|
||||
if (!provider || !projectId || !releaseId) return fallback ?? formatMessage(messages.noTarget)
|
||||
const resolved = upgradeVersionDisplayLabel(versionMetadataQuery.data.value, {
|
||||
provider,
|
||||
projectId,
|
||||
releaseId,
|
||||
})
|
||||
return resolved === releaseId && fallback ? fallback : resolved
|
||||
}
|
||||
|
||||
function draftChoice(contentId: string): string {
|
||||
return (
|
||||
draftConstraints.value.find((constraint) => constraint.contentId === contentId)?.versionId ??
|
||||
AUTOMATIC
|
||||
)
|
||||
}
|
||||
|
||||
function constraintOptions(item: InstanceUpgradePlanItem): string[] {
|
||||
const selected = draftChoice(item.contentId)
|
||||
return [
|
||||
AUTOMATIC,
|
||||
...new Set([...item.candidateReleaseIds, ...(selected === AUTOMATIC ? [] : [selected])]),
|
||||
]
|
||||
}
|
||||
|
||||
function constraintOptionLabel(item: InstanceUpgradePlanItem, value: string): string {
|
||||
if (value === AUTOMATIC) return formatMessage(messages.automatic)
|
||||
const key =
|
||||
item.provider && item.projectId
|
||||
? upgradeVersionCacheKey(item.provider, item.projectId, value)
|
||||
: null
|
||||
const version = key ? versionMetadataQuery.data.value?.get(key) : null
|
||||
if (!version) {
|
||||
return formatMessage(messages.specificVersion, {
|
||||
version: releaseLabel(item.provider, item.projectId, value),
|
||||
})
|
||||
}
|
||||
const channel =
|
||||
version.channel === 'release' || version.channel === 1
|
||||
? formatMessage(messages.channelRelease)
|
||||
: version.channel === 'beta' || version.channel === 2
|
||||
? formatMessage(messages.channelBeta)
|
||||
: formatMessage(messages.channelAlpha)
|
||||
return formatMessage(messages.specificVersionWithChannel, {
|
||||
version: version.version,
|
||||
channel,
|
||||
})
|
||||
}
|
||||
|
||||
function setDraftChoice(item: InstanceUpgradePlanItem, versionId: string) {
|
||||
if (!item.provider || !item.projectId) return
|
||||
const constraint =
|
||||
versionId === AUTOMATIC
|
||||
? null
|
||||
: {
|
||||
contentId: item.contentId,
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
versionId,
|
||||
}
|
||||
draftConstraints.value = setFixedConstraint(draftConstraints.value, constraint, item.contentId)
|
||||
requestError.value = null
|
||||
}
|
||||
|
||||
async function applyCustomChoices() {
|
||||
if (!canApplyCustom.value) return
|
||||
requestBusy.value = true
|
||||
requestError.value = null
|
||||
try {
|
||||
const updatedPlan = await resolve_custom_instance_upgrade_solution(
|
||||
plan.value.id,
|
||||
draftConstraints.value,
|
||||
)
|
||||
flow.setPlan(updatedPlan)
|
||||
draftConstraints.value = updatedPlan.customConstraints.map((item) => ({ ...item }))
|
||||
activeStrategy.value = 'custom'
|
||||
flow.customizeActiveStrategy.value = 'custom'
|
||||
} catch (error) {
|
||||
requestError.value = errorMessage(error)
|
||||
} finally {
|
||||
requestBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function customIssueTitle(issue: InstanceUpgradeIssue): string {
|
||||
return issue.code === 'search_limit_reached'
|
||||
? formatMessage(messages.searchLimitTitle)
|
||||
: formatMessage(messages.conflictTitle)
|
||||
}
|
||||
|
||||
function customIssueBody(issue: InstanceUpgradeIssue): string {
|
||||
if (issue.code === 'search_limit_reached') return formatMessage(messages.searchLimitBody)
|
||||
return issue.message || issue.code
|
||||
}
|
||||
|
||||
function dependencyActionLabel(kind: InstanceUpgradeDependencyChangeKind): string {
|
||||
if (kind === 'add') return formatMessage(messages.dependencyAdd)
|
||||
if (kind === 'upgrade') return formatMessage(messages.dependencyUpgrade)
|
||||
if (kind === 'remove') return formatMessage(messages.dependencyRemove)
|
||||
return formatMessage(messages.dependencyKeep)
|
||||
}
|
||||
|
||||
function dependencyChangeDescription(change: InstanceUpgradeDependencyChange): string {
|
||||
const target = releaseLabel(change.provider, change.projectId, change.targetReleaseId)
|
||||
return change.existingContentId
|
||||
? formatMessage(messages.dependencyReused, {
|
||||
current: releaseLabel(change.provider, change.projectId, change.currentReleaseId),
|
||||
target,
|
||||
})
|
||||
: formatMessage(messages.dependencyNew, { target })
|
||||
}
|
||||
|
||||
async function goBack() {
|
||||
await router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/compatibility`)
|
||||
}
|
||||
|
||||
async function continueUpgrade() {
|
||||
if (!canContinue.value) return
|
||||
await router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}/upgrade/confirm`)
|
||||
}
|
||||
</script>
|
||||
40
apps/app-frontend/src/pages/instance/upgrade/Progress.vue
Normal file
40
apps/app-frontend/src/pages/instance/upgrade/Progress.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<p class="m-0 py-2 text-secondary">{{ formatMessage(messages.opening) }}</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { upgradeProgressDestination, useInstanceUpgradeFlow } from './flow'
|
||||
|
||||
const messages = defineMessages({
|
||||
opening: {
|
||||
id: 'instance.upgrade.progress.opening-downloads',
|
||||
defaultMessage: 'Opening download task…',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
let navigating = false
|
||||
|
||||
flow.registerStepControls(null)
|
||||
|
||||
watch(
|
||||
[flow.jobRecoveryState, flow.activeJobId],
|
||||
async ([recoveryState, jobId]) => {
|
||||
const destination = upgradeProgressDestination(recoveryState, jobId, flow.instanceId.value)
|
||||
if (!destination || navigating) return
|
||||
navigating = true
|
||||
try {
|
||||
await router.replace(destination)
|
||||
} finally {
|
||||
navigating = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
65
apps/app-frontend/src/pages/instance/upgrade/Result.vue
Normal file
65
apps/app-frontend/src/pages/instance/upgrade/Result.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="py-2">
|
||||
<LoadingIndicator v-if="loading" class="pt-8" />
|
||||
<Admonition
|
||||
v-else-if="errorMessage"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.unavailable)"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</Admonition>
|
||||
<UpgradeResultDetails v-else-if="job?.upgrade_result" :result="job.upgrade_result" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition, defineMessages, LoadingIndicator, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { install_job_get, type InstallJobSnapshot } from '@/helpers/install'
|
||||
|
||||
import { isSuccessfulUpgradeJob } from './result'
|
||||
import UpgradeResultDetails from './UpgradeResultDetails.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const job = ref<InstallJobSnapshot | null>(null)
|
||||
const messages = defineMessages({
|
||||
unavailable: {
|
||||
id: 'instance.upgrade.result.unavailable',
|
||||
defaultMessage: 'Upgrade result unavailable',
|
||||
},
|
||||
missing: {
|
||||
id: 'instance.upgrade.result.missing',
|
||||
defaultMessage: 'This persisted upgrade result could not be loaded.',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const jobId = typeof route.query.job === 'string' ? route.query.job : null
|
||||
if (!jobId) {
|
||||
await router.replace('/downloads')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const persisted = await install_job_get(jobId)
|
||||
const routeInstanceId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (
|
||||
!isSuccessfulUpgradeJob(persisted) ||
|
||||
persisted.upgrade_result?.sourceInstanceId !== routeInstanceId
|
||||
) {
|
||||
errorMessage.value = formatMessage(messages.missing)
|
||||
return
|
||||
}
|
||||
job.value = persisted
|
||||
} catch {
|
||||
errorMessage.value = formatMessage(messages.missing)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
429
apps/app-frontend/src/pages/instance/upgrade/Select.vue
Normal file
429
apps/app-frontend/src/pages/instance/upgrade/Select.vue
Normal file
@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h2>
|
||||
<p class="m-0 max-w-2xl text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.current) }}
|
||||
</h3>
|
||||
<p class="mb-1 mt-3 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.minecraftVersion, { version: instance.game_version }) }}
|
||||
</p>
|
||||
<p class="m-0 text-secondary">{{ currentLoaderLabel }}</p>
|
||||
</Card>
|
||||
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.target) }}
|
||||
</h3>
|
||||
<label class="mb-2 mt-3 block text-sm font-medium text-contrast">
|
||||
{{ formatMessage(messages.minecraft) }}
|
||||
</label>
|
||||
<div v-if="gameVersionsQuery.isPending.value" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.loadingVersions) }}
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-else-if="targetVersions.length"
|
||||
v-model="selectedGameVersion"
|
||||
class="max-w-full"
|
||||
:name="formatMessage(messages.targetVersionInput)"
|
||||
:options="targetVersions"
|
||||
:disabled="flow.busy.value"
|
||||
/>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noNewerRelease) }}
|
||||
</p>
|
||||
<template v-if="isFabric && selectedGameVersion">
|
||||
<label class="mb-2 mt-4 block text-sm font-medium text-contrast">
|
||||
{{ formatMessage(messages.fabricVersion) }}
|
||||
</label>
|
||||
<DropdownSelect
|
||||
v-model="selectedFabricVersion"
|
||||
class="max-w-full"
|
||||
:name="formatMessage(messages.fabricVersion)"
|
||||
:options="fabricLoaderOptions"
|
||||
:display-name="fabricLoaderOptionLabel"
|
||||
:disabled="flow.busy.value"
|
||||
auto-placement
|
||||
/>
|
||||
<p
|
||||
v-if="
|
||||
fabricLoaderVersionsQuery.isPending.value &&
|
||||
fabricLoaderVersionsQuery.isFetching.value
|
||||
"
|
||||
class="mb-0 mt-2 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.loadingFabricVersions) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="fabricLoaderVersionsQuery.isError.value"
|
||||
class="mb-0 mt-2 text-sm text-orange"
|
||||
>
|
||||
{{ formatMessage(messages.fabricVersionsError) }}
|
||||
</p>
|
||||
<p v-else-if="manualFabricSelectionUnavailable" class="mb-0 mt-2 text-sm text-secondary">
|
||||
{{ formatMessage(messages.manualFabricVersionUnavailable) }}
|
||||
</p>
|
||||
<p v-else-if="noNonDowngradeFabricVersion" class="mb-0 mt-2 text-sm text-orange">
|
||||
{{ formatMessage(messages.noNonDowngradeFabricVersion) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-else-if="!isFabric" class="mb-0 mt-3 text-secondary">
|
||||
{{ formatLoaderLabel(instance.loader) }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="gameVersionsQuery.isError.value"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.metadataErrorTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.metadataErrorBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="versionTargets && !versionTargets.currentFound"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.currentVersionMissingTitle)"
|
||||
>
|
||||
{{ formatMessage(messages.currentVersionMissingBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="flow.error.value"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.planningErrorTitle)"
|
||||
>
|
||||
{{ errorMessage(flow.error.value) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="flow.busy.value" class="flex items-center gap-2 text-secondary" role="status">
|
||||
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
|
||||
{{ formatMessage(messages.planningStatus, { count: snapshotItemCount }) }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Card,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
formatLoaderLabel,
|
||||
loaderVersionsForGameVersion,
|
||||
scopedLoaderMetadataQueryKey,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { plan_instance_upgrade } from '@/helpers/instance-upgrade'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { Manifest } from '@/helpers/types'
|
||||
import { compareSemanticVersions } from '@/helpers/version-compatibility'
|
||||
|
||||
import {
|
||||
AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
automaticFabricLoaderTargetAvailable,
|
||||
fabricLoaderVersionForTarget,
|
||||
fabricUpgradeLoaderVersions,
|
||||
inferShaderRuntime,
|
||||
newerStableGameVersions,
|
||||
preserveFabricLoaderSelection,
|
||||
resolveUpgradePlanSelection,
|
||||
shouldReuseUpgradePlan,
|
||||
} from './analysis'
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
import { isCurrentUpgradeSelectPlanning } from './planning-navigation'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.select.title', defaultMessage: 'Upgrade instance' },
|
||||
description: {
|
||||
id: 'instance.upgrade.select.description',
|
||||
defaultMessage: 'Choose which Minecraft version this instance should be upgraded to.',
|
||||
},
|
||||
current: { id: 'instance.upgrade.select.current', defaultMessage: 'Current' },
|
||||
target: { id: 'instance.upgrade.select.target', defaultMessage: 'Target' },
|
||||
minecraft: { id: 'instance.upgrade.select.minecraft', defaultMessage: 'Minecraft version' },
|
||||
minecraftVersion: {
|
||||
id: 'instance.upgrade.select.minecraft-version',
|
||||
defaultMessage: 'Minecraft {version}',
|
||||
},
|
||||
targetVersionInput: {
|
||||
id: 'instance.upgrade.select.target-version-input',
|
||||
defaultMessage: 'Target Minecraft version',
|
||||
},
|
||||
loadingVersions: {
|
||||
id: 'instance.upgrade.select.loading-versions',
|
||||
defaultMessage: 'Loading Minecraft versions…',
|
||||
},
|
||||
fabricVersion: {
|
||||
id: 'instance.upgrade.select.fabric-version',
|
||||
defaultMessage: 'Fabric version',
|
||||
},
|
||||
automatic: { id: 'instance.upgrade.select.automatic', defaultMessage: 'Automatic' },
|
||||
loadingFabricVersions: {
|
||||
id: 'instance.upgrade.select.loading-fabric-versions',
|
||||
defaultMessage: 'Loading Fabric versions…',
|
||||
},
|
||||
fabricVersionsError: {
|
||||
id: 'instance.upgrade.select.fabric-versions-error',
|
||||
defaultMessage: 'Fabric versions could not be loaded. Automatic remains available.',
|
||||
},
|
||||
manualFabricVersionUnavailable: {
|
||||
id: 'instance.upgrade.select.manual-fabric-version-unavailable',
|
||||
defaultMessage: 'Manual Fabric version selection is unavailable.',
|
||||
},
|
||||
noNonDowngradeFabricVersion: {
|
||||
id: 'instance.upgrade.select.no-non-downgrade-fabric-version',
|
||||
defaultMessage: 'No Fabric version that avoids downgrading is available for this target.',
|
||||
},
|
||||
noNewerRelease: {
|
||||
id: 'instance.upgrade.select.no-newer-release',
|
||||
defaultMessage: 'This instance already uses the latest stable Minecraft version.',
|
||||
},
|
||||
metadataErrorTitle: {
|
||||
id: 'instance.upgrade.select.metadata-error-title',
|
||||
defaultMessage: 'Minecraft versions could not be loaded',
|
||||
},
|
||||
metadataErrorBody: {
|
||||
id: 'instance.upgrade.select.metadata-error-body',
|
||||
defaultMessage: 'Check your connection and try again.',
|
||||
},
|
||||
currentVersionMissingTitle: {
|
||||
id: 'instance.upgrade.select.current-version-missing-title',
|
||||
defaultMessage: 'Current version not found in metadata',
|
||||
},
|
||||
currentVersionMissingBody: {
|
||||
id: 'instance.upgrade.select.current-version-missing-body',
|
||||
defaultMessage: 'Stable releases are shown without guessing their numeric order.',
|
||||
},
|
||||
planningErrorTitle: {
|
||||
id: 'instance.upgrade.select.planning-error-title',
|
||||
defaultMessage: 'Compatibility analysis failed',
|
||||
},
|
||||
planningStatus: {
|
||||
id: 'instance.upgrade.select.planning-status',
|
||||
defaultMessage: 'Analyzing compatibility for {count} content items…',
|
||||
},
|
||||
checkCompatibility: {
|
||||
id: 'instance.upgrade.select.check-compatibility',
|
||||
defaultMessage: 'Check compatibility',
|
||||
},
|
||||
reviewCompatibility: {
|
||||
id: 'instance.upgrade.select.review-compatibility',
|
||||
defaultMessage: 'Review compatibility',
|
||||
},
|
||||
})
|
||||
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const instance = computed(() => flow.instance.value)
|
||||
const selectedGameVersion = ref<string | null>(flow.targetEnvironment.value?.gameVersion ?? null)
|
||||
const selectedFabricVersion = ref(
|
||||
flow.targetEnvironment.value?.modLoaderVersion ?? AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
)
|
||||
const isFabric = computed(() => instance.value.loader === 'fabric')
|
||||
|
||||
const gameVersionsQuery = useQuery({
|
||||
queryKey: ['instance-upgrade', 'game-versions'],
|
||||
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
|
||||
})
|
||||
const contentDataQuery = useQuery({
|
||||
queryKey: computed(() => ['instance-upgrade', 'content-data', flow.instanceId.value]),
|
||||
queryFn: () => loadInstanceContentData(flow.instanceId.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const fabricLoaderVersionsQuery = useQuery({
|
||||
queryKey: computed(() =>
|
||||
scopedLoaderMetadataQueryKey('instance-upgrade', 'fabric', selectedGameVersion.value ?? ''),
|
||||
),
|
||||
queryFn: ({ queryKey }) => get_loader_versions(queryKey[2], queryKey[3]) as Promise<Manifest>,
|
||||
enabled: computed(() => isFabric.value && selectedGameVersion.value !== null),
|
||||
})
|
||||
|
||||
const versionTargets = computed(() => {
|
||||
if (!gameVersionsQuery.data.value) return null
|
||||
return newerStableGameVersions(gameVersionsQuery.data.value, instance.value.game_version)
|
||||
})
|
||||
const targetVersions = computed(() => versionTargets.value?.versions ?? [])
|
||||
const currentFabricVersionComparable = computed(
|
||||
() =>
|
||||
Boolean(instance.value.loader_version) &&
|
||||
compareSemanticVersions(instance.value.loader_version!, instance.value.loader_version!) !==
|
||||
null,
|
||||
)
|
||||
const availableFabricLoaderVersions = computed(() =>
|
||||
fabricUpgradeLoaderVersions(
|
||||
instance.value.loader_version,
|
||||
loaderVersionsForGameVersion(
|
||||
fabricLoaderVersionsQuery.data.value,
|
||||
selectedGameVersion.value ?? '',
|
||||
).map((version) => version.id),
|
||||
),
|
||||
)
|
||||
const manualFabricSelectionUnavailable = computed(
|
||||
() => isFabric.value && !currentFabricVersionComparable.value,
|
||||
)
|
||||
const noNonDowngradeFabricVersion = computed(
|
||||
() =>
|
||||
isFabric.value &&
|
||||
fabricLoaderVersionsQuery.isSuccess.value &&
|
||||
currentFabricVersionComparable.value &&
|
||||
availableFabricLoaderVersions.value.length === 0,
|
||||
)
|
||||
const fabricLoaderOptions = computed(() => {
|
||||
const exactVersions = fabricLoaderVersionsQuery.isSuccess.value
|
||||
? availableFabricLoaderVersions.value
|
||||
: selectedFabricVersion.value !== AUTOMATIC_FABRIC_LOADER_VERSION
|
||||
? [selectedFabricVersion.value]
|
||||
: []
|
||||
return [AUTOMATIC_FABRIC_LOADER_VERSION, ...exactVersions]
|
||||
})
|
||||
const currentLoaderLabel = computed(() => {
|
||||
const loader = formatLoaderLabel(instance.value.loader)
|
||||
return instance.value.loader_version ? `${loader} ${instance.value.loader_version}` : loader
|
||||
})
|
||||
const snapshotItemCount = computed(() => contentDataQuery.data.value?.snapshot.items.length ?? 0)
|
||||
const canPlan = computed(
|
||||
() =>
|
||||
selectedGameVersion.value !== null &&
|
||||
targetVersions.value.includes(selectedGameVersion.value) &&
|
||||
!flow.busy.value &&
|
||||
!gameVersionsQuery.isError.value &&
|
||||
(!isFabric.value ||
|
||||
(selectedFabricVersion.value === AUTOMATIC_FABRIC_LOADER_VERSION &&
|
||||
automaticFabricLoaderTargetAvailable(
|
||||
fabricLoaderVersionsQuery.isSuccess.value,
|
||||
currentFabricVersionComparable.value,
|
||||
availableFabricLoaderVersions.value,
|
||||
)) ||
|
||||
availableFabricLoaderVersions.value.includes(selectedFabricVersion.value)),
|
||||
)
|
||||
|
||||
watch(
|
||||
versionTargets,
|
||||
(targets) => {
|
||||
if (!targets) return
|
||||
if (selectedGameVersion.value && targets.versions.includes(selectedGameVersion.value)) {
|
||||
return
|
||||
}
|
||||
selectedGameVersion.value = targets.versions[0] ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedGameVersion, () => (flow.error.value = null))
|
||||
watch(selectedFabricVersion, () => (flow.error.value = null))
|
||||
watch(
|
||||
[() => fabricLoaderVersionsQuery.isSuccess.value, availableFabricLoaderVersions],
|
||||
([loaded, versions]) => {
|
||||
if (!loaded) return
|
||||
selectedFabricVersion.value = preserveFabricLoaderSelection(
|
||||
selectedFabricVersion.value,
|
||||
versions,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function fabricLoaderOptionLabel(version: string) {
|
||||
return version === AUTOMATIC_FABRIC_LOADER_VERSION ? formatMessage(messages.automatic) : version
|
||||
}
|
||||
|
||||
const requestedTargetEnvironment = computed(() =>
|
||||
selectedGameVersion.value
|
||||
? {
|
||||
gameVersion: selectedGameVersion.value,
|
||||
modLoader: instance.value.loader,
|
||||
modLoaderVersion: isFabric.value
|
||||
? fabricLoaderVersionForTarget(selectedFabricVersion.value)
|
||||
: null,
|
||||
shaderRuntime: inferShaderRuntime(instance.value, contentDataQuery.data.value?.snapshot),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const reusesPlan = computed(() =>
|
||||
shouldReuseUpgradePlan(flow.instanceId.value, flow.plan.value, requestedTargetEnvironment.value),
|
||||
)
|
||||
|
||||
function registerControls() {
|
||||
flow.registerStepControls({
|
||||
canNext: canPlan,
|
||||
busy: flow.busy,
|
||||
nextLabel: formatMessage(
|
||||
reusesPlan.value ? messages.reviewCompatibility : messages.checkCompatibility,
|
||||
),
|
||||
onNext: startPlanning,
|
||||
onBack: () => router.push(`/instance/${encodeURIComponent(flow.instanceId.value)}`),
|
||||
})
|
||||
}
|
||||
onMounted(registerControls)
|
||||
watch([canPlan, reusesPlan, () => flow.busy.value], registerControls)
|
||||
let planningGeneration = 0
|
||||
let disposed = false
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
planningGeneration += 1
|
||||
flow.busy.value = false
|
||||
flow.registerStepControls(null)
|
||||
})
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (typeof error === 'object' && error && 'message' in error) return String(error.message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function startPlanning() {
|
||||
if (!canPlan.value || !selectedGameVersion.value) return
|
||||
|
||||
const targetEnvironment = requestedTargetEnvironment.value!
|
||||
const instanceId = flow.instanceId.value
|
||||
const generation = ++planningGeneration
|
||||
flow.error.value = null
|
||||
flow.busy.value = true
|
||||
try {
|
||||
const planned = await resolveUpgradePlanSelection(
|
||||
instanceId,
|
||||
flow.plan.value,
|
||||
targetEnvironment,
|
||||
plan_instance_upgrade,
|
||||
)
|
||||
const routeInstanceId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (
|
||||
!isCurrentUpgradeSelectPlanning(
|
||||
disposed,
|
||||
generation,
|
||||
planningGeneration,
|
||||
route.name,
|
||||
routeInstanceId,
|
||||
instanceId,
|
||||
)
|
||||
)
|
||||
return
|
||||
if (!planned.reused) flow.setPlan(planned.plan)
|
||||
flow.setTargetEnvironment(planned.plan.targetEnvironment)
|
||||
await router.push(`/instance/${encodeURIComponent(instanceId)}/upgrade/compatibility`)
|
||||
} catch (error) {
|
||||
if (!disposed && generation === planningGeneration) flow.error.value = error
|
||||
} finally {
|
||||
if (!disposed && generation === planningGeneration) flow.busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
:shown="true"
|
||||
:aria-label="formatMessage(messages.aria)"
|
||||
hide-when-modal-open
|
||||
allow-overflow
|
||||
>
|
||||
<div
|
||||
class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3"
|
||||
>
|
||||
<div class="justify-self-start">
|
||||
<ButtonStyled v-if="!progress.complete && controls" type="outlined" size="small">
|
||||
<button :disabled="!controls" @click="controls?.onBack()">
|
||||
<ArrowLeftIcon aria-hidden="true" />
|
||||
<span class="bar-label">{{ formatMessage(messages.back) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="relative min-w-0 flex-1 select-none"
|
||||
tabindex="0"
|
||||
:aria-label="formatMessage(messages.steps)"
|
||||
@mouseenter="progressOpen = true"
|
||||
@mouseleave="progressOpen = false"
|
||||
@focus="progressOpen = true"
|
||||
@blur="progressOpen = false"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center gap-1.5 truncate text-center text-sm text-secondary"
|
||||
>
|
||||
<CheckCircleIcon
|
||||
v-if="progress.complete"
|
||||
class="size-4 shrink-0 text-green"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<template v-if="progress.complete">{{ formatMessage(messages.complete) }}</template>
|
||||
<template v-else>
|
||||
{{ progress.currentIndex + 1 }} / {{ progress.steps.length }} ·
|
||||
{{ formatMessage(stepLabels[progress.currentIndex]) }}
|
||||
</template>
|
||||
</span>
|
||||
<div
|
||||
v-if="progressOpen"
|
||||
class="absolute bottom-[calc(100%+0.75rem)] left-1/2 z-10 flex w-max max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col gap-2 rounded-md border border-solid border-surface-5 bg-surface-3 px-3 py-2 text-sm shadow-lg"
|
||||
style="background-color: var(--color-tooltip-bg)"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in progress.steps"
|
||||
:key="step"
|
||||
class="flex items-center gap-2 whitespace-nowrap"
|
||||
:class="stepClass(index)"
|
||||
>
|
||||
<CheckCircleIcon
|
||||
v-if="progress.complete || index < progress.currentIndex"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="size-3 shrink-0 rounded-full border-2 border-solid border-current"
|
||||
:class="{ 'bg-current': index === progress.currentIndex }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(stepLabels[index]) }}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="justify-self-end">
|
||||
<span v-tooltip="blockerTooltip" tabindex="0" :aria-label="blockerTooltip">
|
||||
<ButtonStyled v-if="!progress.complete && controls" color="brand" size="small">
|
||||
<button :disabled="!controls || !canNext || busy" @click="controls?.onNext()">
|
||||
<SpinnerIcon v-if="busy" class="animate-spin" aria-hidden="true" />
|
||||
<CircleArrowRightIcon v-else aria-hidden="true" />
|
||||
<span class="bar-label">{{
|
||||
controls?.nextLabel ?? formatMessage(messages.next)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftIcon, CheckCircleIcon, CircleArrowRightIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, FloatingActionBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
import { upgradeControlEnabled, upgradeProgressModel } from './flow-controls'
|
||||
|
||||
const messages = defineMessages({
|
||||
aria: { id: 'instance.upgrade.flow.aria', defaultMessage: 'Instance upgrade navigation' },
|
||||
back: { id: 'instance.upgrade.flow.back', defaultMessage: 'Previous' },
|
||||
next: { id: 'instance.upgrade.flow.next', defaultMessage: 'Next' },
|
||||
steps: { id: 'instance.upgrade.flow.steps', defaultMessage: 'Upgrade steps' },
|
||||
target: { id: 'instance.upgrade.flow.target', defaultMessage: 'Upgrade target' },
|
||||
issues: { id: 'instance.upgrade.flow.issues', defaultMessage: 'Resolve issues' },
|
||||
preferences: { id: 'instance.upgrade.flow.preferences', defaultMessage: 'Upgrade preferences' },
|
||||
confirm: { id: 'instance.upgrade.flow.confirm', defaultMessage: 'Confirm upgrade' },
|
||||
progress: { id: 'instance.upgrade.flow.progress', defaultMessage: 'Upgrading' },
|
||||
complete: { id: 'instance.upgrade.flow.complete', defaultMessage: 'Upgrade complete' },
|
||||
resolveBlockers: {
|
||||
id: 'instance.upgrade.compatibility.resolve-blockers-tooltip',
|
||||
defaultMessage: 'Please resolve all blocking items before continuing.',
|
||||
},
|
||||
chooseSharedMode: {
|
||||
id: 'instance.upgrade.confirm.choose-shared-mode-tooltip',
|
||||
defaultMessage: 'Choose how this shared instance should be upgraded.',
|
||||
},
|
||||
})
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
const progressOpen = ref(false)
|
||||
const stepLabels = [
|
||||
messages.target,
|
||||
messages.issues,
|
||||
messages.preferences,
|
||||
messages.confirm,
|
||||
messages.progress,
|
||||
]
|
||||
const progress = computed(() => upgradeProgressModel(route.path))
|
||||
const controls = computed(() => flow.controls.value)
|
||||
const canNext = computed(() => upgradeControlEnabled(flow.controls.value?.canNext))
|
||||
const busy = computed(() => upgradeControlEnabled(flow.controls.value?.busy))
|
||||
const showBlockerTooltip = computed(
|
||||
() =>
|
||||
route.path.endsWith('/upgrade/compatibility') &&
|
||||
(flow.plan.value?.blockingIssues.length ?? 0) > 0 &&
|
||||
!busy.value,
|
||||
)
|
||||
const blockerTooltip = computed(() => {
|
||||
if (
|
||||
route.path.endsWith('/upgrade/confirm') &&
|
||||
flow.instance.value &&
|
||||
flow.sharedUpgradeMode.value === null &&
|
||||
(flow.instance.value.link?.type === 'shared_instance' ||
|
||||
Boolean(flow.instance.value.symlink_target))
|
||||
) {
|
||||
return formatMessage(messages.chooseSharedMode)
|
||||
}
|
||||
return showBlockerTooltip.value ? formatMessage(messages.resolveBlockers) : undefined
|
||||
})
|
||||
|
||||
function stepClass(index: number) {
|
||||
if (progress.value.complete || index < progress.value.currentIndex) return 'text-green'
|
||||
if (index === progress.value.currentIndex) return 'font-semibold text-brand'
|
||||
return 'text-secondary'
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card v-if="warnings.length" class="!m-0 !p-0">
|
||||
<Accordion
|
||||
class="block w-full"
|
||||
:open-by-default="warningsDefaultOpen"
|
||||
button-class="group flex !w-full cursor-pointer border-0 bg-transparent p-4 text-left hover:bg-surface-3 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-brand-shadow"
|
||||
content-class="border-0 border-t border-solid border-divider p-3"
|
||||
@on-open="warningsOpen = true"
|
||||
@on-close="warningsOpen = false"
|
||||
>
|
||||
<template #button="{ open }">
|
||||
<div data-warning-trigger-content class="flex w-full min-w-0 flex-col gap-2">
|
||||
<div class="flex w-full min-w-0 items-center gap-2">
|
||||
<TriangleAlertIcon class="size-5 shrink-0 text-orange" aria-hidden="true" />
|
||||
<strong class="min-w-0">{{ formatMessage(messages.warningsTitle) }}</strong>
|
||||
<Badge color="orange" :type="String(warnings.length)" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 shrink-0 text-secondary transition-transform duration-300 group-hover:text-primary"
|
||||
:class="{ 'rotate-180': open }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 pl-7 text-xs text-secondary">
|
||||
<span v-if="warningSummary.local">{{ summaryLabel('local') }}</span>
|
||||
<span v-if="warningSummary.kept">{{ summaryLabel('kept') }}</span>
|
||||
<span v-if="warningSummary.fallback">{{ summaryLabel('fallback') }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-7 text-xs text-secondary">{{ formatMessage(messages.reassurance) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="warningsOpen" class="flex flex-col gap-3">
|
||||
<StyledInput
|
||||
v-model="warningSearch"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.warningSearch)"
|
||||
:aria-label="formatMessage(messages.warningSearch)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
:aria-label="formatMessage(messages.warningFilters)"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-for="option in warningFilters"
|
||||
:key="option.value"
|
||||
size="small"
|
||||
:type="warningFilter === option.value ? 'standard' : 'outlined'"
|
||||
:color="warningFilter === option.value ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
:aria-pressed="warningFilter === option.value"
|
||||
@click="warningFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">{{ warningPaginationLabel }}</span>
|
||||
<div v-if="warningPage.items.length">
|
||||
<ul class="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<li
|
||||
v-for="warning in warningPage.items"
|
||||
:key="warning.key"
|
||||
data-upgrade-warning-row
|
||||
class="rounded-md bg-surface-2 p-3"
|
||||
>
|
||||
<strong class="block text-sm text-contrast">{{ warningHeadline(warning) }}</strong>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">{{ warningDescription(warning) }}</p>
|
||||
<div class="mt-2 text-sm font-medium text-contrast">
|
||||
{{ warningIdentity(warning) }}
|
||||
</div>
|
||||
<div class="text-xs text-secondary">{{ warningContext(warning) }}</div>
|
||||
<details v-if="hasTechnicalDetails(warning)" class="mt-2 text-xs text-secondary">
|
||||
<summary class="cursor-pointer">
|
||||
{{ formatMessage(messages.technicalDetails) }}
|
||||
</summary>
|
||||
<dl class="mb-0 mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<template v-if="warning.relativePath">
|
||||
<dt>{{ formatMessage(messages.relativePath) }}</dt>
|
||||
<dd class="m-0 break-all">
|
||||
<code>{{ warning.relativePath }}</code>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="warning.code">
|
||||
<dt>{{ formatMessage(messages.warningCode) }}</dt>
|
||||
<dd class="m-0">
|
||||
<code>{{ warning.code }}</code>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="warning.provider || warning.projectId">
|
||||
<dt>{{ formatMessage(messages.providerIdentity) }}</dt>
|
||||
<dd class="m-0 break-all">
|
||||
{{ warning.provider }} · {{ warning.projectId }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="m-0 rounded-md bg-surface-2 p-4 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.noWarningMatches) }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-sm text-secondary">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="warningPage.page <= 1" @click="warningPageNumber -= 1">
|
||||
{{ formatMessage(messages.previous) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span>{{ warningPage.page }} / {{ warningPage.pageCount }}</span>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button
|
||||
:disabled="warningPage.page >= warningPage.pageCount"
|
||||
@click="warningPageNumber += 1"
|
||||
>
|
||||
{{ formatMessage(messages.next) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Card>
|
||||
|
||||
<Card class="!m-0 !p-0">
|
||||
<Accordion
|
||||
button-class="flex w-full cursor-pointer items-center border-0 bg-transparent p-4 text-left hover:bg-surface-3"
|
||||
content-class="border-0 border-t border-solid border-divider p-4"
|
||||
@on-open="detailsOpen = true"
|
||||
@on-close="detailsOpen = false"
|
||||
>
|
||||
<template #title
|
||||
><strong>{{ formatMessage(messages.detailsTitle) }}</strong></template
|
||||
>
|
||||
<div v-if="detailsOpen" class="flex flex-col gap-3">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
:aria-label="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
:aria-label="formatMessage(messages.filters)"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-for="option in filters"
|
||||
:key="option.value"
|
||||
size="small"
|
||||
:type="filter === option.value ? 'standard' : 'outlined'"
|
||||
:color="filter === option.value ? 'brand' : 'standard'"
|
||||
>
|
||||
<button :aria-pressed="filter === option.value" @click="filter = option.value">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleRows.length" class="divide-y divide-divider">
|
||||
<div
|
||||
v-for="item in visibleRows"
|
||||
:key="item.key"
|
||||
data-upgrade-detail-row
|
||||
class="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<RouterLink
|
||||
v-if="item.path"
|
||||
:to="item.path"
|
||||
class="block truncate font-medium text-contrast hover:text-brand hover:underline"
|
||||
>{{ item.title }}
|
||||
<ExternalIcon class="inline size-3" aria-hidden="true" /></RouterLink
|
||||
><span v-else class="block truncate font-medium text-contrast">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<div class="truncate text-xs text-secondary">{{ item.context }}</div>
|
||||
<div class="flex flex-wrap items-center gap-x-2 text-secondary">
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.currentReleaseId"
|
||||
:label="item.current"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.currentReleaseId"
|
||||
/><span v-else>{{ item.current }}</span>
|
||||
<span v-if="item.target" aria-hidden="true">→</span>
|
||||
<UpgradeVersionChangelogPopout
|
||||
v-if="item.targetReleaseId"
|
||||
:label="item.target"
|
||||
:provider="item.provider"
|
||||
:project-id="item.projectId"
|
||||
:release-id="item.targetReleaseId"
|
||||
/><span v-else-if="item.target">{{ item.target }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :color="item.badgeColor" :type="item.actionLabel" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="m-0 rounded-md bg-surface-2 p-4 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.noMatches) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-sm text-secondary">
|
||||
<span>{{ paginationLabel }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="pageData.page <= 1" @click="page -= 1">
|
||||
{{ formatMessage(messages.previous) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span>{{ pageData.page }} / {{ pageData.pageCount }}</span>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="pageData.page >= pageData.pageCount" @click="page += 1">
|
||||
{{ formatMessage(messages.next) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, ExternalIcon, SearchIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
defineMessages,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_content_snapshot } from '@/helpers/instance'
|
||||
import type { InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
import { shouldExpandUpgradeWarningsByDefault } from '@/helpers/post-upgrade-notice'
|
||||
import { upgradeProjectPath } from '@/helpers/upgrade-return-state'
|
||||
import {
|
||||
loadUpgradeProjectDisplayMetadata,
|
||||
loadUpgradeVersionDisplayMetadata,
|
||||
upgradeProjectDisplayCacheKey,
|
||||
upgradeVersionDisplayLabel,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
import {
|
||||
filterUpgradeDetailItems,
|
||||
paginateUpgradeDetailItems,
|
||||
type UpgradeDetailFilter,
|
||||
type UpgradeDetailItem,
|
||||
upgradeDetailItems,
|
||||
upgradeDetailProjectIdentities,
|
||||
upgradeDetailReleaseIdentities,
|
||||
} from './upgrade-result-presentation'
|
||||
import {
|
||||
filterUpgradeWarnings,
|
||||
paginateUpgradeWarnings,
|
||||
summarizeUpgradeWarnings,
|
||||
upgradeResultWarningRows,
|
||||
type UpgradeWarningCategory,
|
||||
upgradeWarningCategory,
|
||||
upgradeWarningContentKind,
|
||||
upgradeWarningDisplayName,
|
||||
type UpgradeWarningFilter,
|
||||
type UpgradeWarningRow,
|
||||
} from './upgrade-warning'
|
||||
import UpgradeVersionChangelogPopout from './UpgradeVersionChangelogPopout.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
warningsTitle: {
|
||||
id: 'instance.upgrade.result.warnings-title',
|
||||
defaultMessage: 'Compatibility warnings',
|
||||
},
|
||||
summaryLocal: {
|
||||
id: 'instance.upgrade.result.warning-summary-local',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# local item could not be identified} other {# local items could not be identified}}',
|
||||
},
|
||||
summaryKept: {
|
||||
id: 'instance.upgrade.result.warning-summary-kept',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# item kept its previous version} other {# items kept their previous version}}',
|
||||
},
|
||||
summaryFallback: {
|
||||
id: 'instance.upgrade.result.warning-summary-fallback',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# item used another compatibility fallback} other {# items used another compatibility fallback}}',
|
||||
},
|
||||
reassurance: {
|
||||
id: 'instance.upgrade.result.warning-reassurance',
|
||||
defaultMessage:
|
||||
'These warnings did not prevent the upgrade from completing. If the upgraded instance runs normally, no immediate action is required.',
|
||||
},
|
||||
warningSearch: {
|
||||
id: 'instance.upgrade.result.warning-search',
|
||||
defaultMessage: 'Search compatibility warnings',
|
||||
},
|
||||
warningFilters: {
|
||||
id: 'instance.upgrade.result.warning-filters',
|
||||
defaultMessage: 'Filter compatibility warnings',
|
||||
},
|
||||
warningFilterUnidentified: {
|
||||
id: 'instance.upgrade.result.warning-filter-unidentified',
|
||||
defaultMessage: 'Unidentified',
|
||||
},
|
||||
warningFilterFallback: {
|
||||
id: 'instance.upgrade.result.warning-filter-fallback',
|
||||
defaultMessage: 'Compatibility fallback',
|
||||
},
|
||||
noWarningMatches: {
|
||||
id: 'instance.upgrade.result.no-matching-warnings',
|
||||
defaultMessage: 'No matching compatibility warnings.',
|
||||
},
|
||||
unidentifiedHeadline: {
|
||||
id: 'instance.upgrade.result.warning-unidentified-headline',
|
||||
defaultMessage: 'This content was kept unchanged',
|
||||
},
|
||||
unidentifiedDescription: {
|
||||
id: 'instance.upgrade.result.warning-unidentified-description',
|
||||
defaultMessage:
|
||||
'The launcher could not confirm whether it supports Minecraft {targetVersion}. If you notice problems, try disabling it temporarily.',
|
||||
},
|
||||
unsupportedHeadline: {
|
||||
id: 'instance.upgrade.result.warning-unsupported-headline',
|
||||
defaultMessage: 'This content type was kept unchanged',
|
||||
},
|
||||
unsupportedDescription: {
|
||||
id: 'instance.upgrade.result.warning-unsupported-description',
|
||||
defaultMessage:
|
||||
'This content type cannot be upgraded automatically. Check for a manual update if problems occur.',
|
||||
},
|
||||
keptHeadline: {
|
||||
id: 'instance.upgrade.result.warning-kept-headline',
|
||||
defaultMessage: 'The previous version was kept',
|
||||
},
|
||||
keptDescription: {
|
||||
id: 'instance.upgrade.result.warning-kept-description',
|
||||
defaultMessage:
|
||||
'No verified compatible replacement was selected. Update it manually or disable it if the game has problems.',
|
||||
},
|
||||
prereleaseHeadline: {
|
||||
id: 'instance.upgrade.result.warning-prerelease-headline',
|
||||
defaultMessage: 'A prerelease version was used',
|
||||
},
|
||||
prereleaseDescription: {
|
||||
id: 'instance.upgrade.result.warning-prerelease-description',
|
||||
defaultMessage:
|
||||
'This item used an alpha, beta, or release-candidate build because no stable target build was available.',
|
||||
},
|
||||
shaderHeadline: {
|
||||
id: 'instance.upgrade.result.warning-shader-headline',
|
||||
defaultMessage: 'Shader compatibility could not be confirmed',
|
||||
},
|
||||
shaderDescription: {
|
||||
id: 'instance.upgrade.result.warning-shader-description',
|
||||
defaultMessage:
|
||||
'The shader was preserved, but compatibility with the target shader runtime is unknown. Disable it if rendering problems occur.',
|
||||
},
|
||||
dependencyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-dependency-headline',
|
||||
defaultMessage: 'A dependency needed a compatibility fallback',
|
||||
},
|
||||
dependencyDescription: {
|
||||
id: 'instance.upgrade.result.warning-dependency-description',
|
||||
defaultMessage:
|
||||
'The upgrade completed, but this dependency could not be verified normally. Review it if the game fails to start.',
|
||||
},
|
||||
conflictHeadline: {
|
||||
id: 'instance.upgrade.result.warning-conflict-headline',
|
||||
defaultMessage: 'Some dependency requirements conflicted',
|
||||
},
|
||||
conflictDescription: {
|
||||
id: 'instance.upgrade.result.warning-conflict-description',
|
||||
defaultMessage:
|
||||
'This content requested dependency versions that could not all be used together. Review its dependencies if the game fails to start.',
|
||||
},
|
||||
missingDependencyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-missing-dependency-headline',
|
||||
defaultMessage: 'A required dependency could not be found',
|
||||
},
|
||||
missingDependencyDescription: {
|
||||
id: 'instance.upgrade.result.warning-missing-dependency-description',
|
||||
defaultMessage:
|
||||
'The provider did not offer a required dependency for the target environment. Install a compatible dependency manually if needed.',
|
||||
},
|
||||
incompatibleDependencyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-incompatible-dependency-headline',
|
||||
defaultMessage: 'A dependency may be incompatible',
|
||||
},
|
||||
incompatibleDependencyDescription: {
|
||||
id: 'instance.upgrade.result.warning-incompatible-dependency-description',
|
||||
defaultMessage:
|
||||
'A dependency could not satisfy the selected versions. Review or disable the affected content if the game has problems.',
|
||||
},
|
||||
searchLimitHeadline: {
|
||||
id: 'instance.upgrade.result.warning-search-limit-headline',
|
||||
defaultMessage: 'Compatibility could not be fully verified',
|
||||
},
|
||||
searchLimitDescription: {
|
||||
id: 'instance.upgrade.result.warning-search-limit-description',
|
||||
defaultMessage:
|
||||
'The bounded compatibility search could not prove a complete result. Review this content if the game has problems.',
|
||||
},
|
||||
resolvedHeadline: {
|
||||
id: 'instance.upgrade.result.warning-resolved-headline',
|
||||
defaultMessage: 'A compatibility fallback was applied',
|
||||
},
|
||||
resolvedDescription: {
|
||||
id: 'instance.upgrade.result.warning-resolved-description',
|
||||
defaultMessage:
|
||||
'This warning occurred while planning, but the content was upgraded successfully. No immediate action is required.',
|
||||
},
|
||||
disabledHeadline: {
|
||||
id: 'instance.upgrade.result.warning-disabled-headline',
|
||||
defaultMessage: 'This content was disabled',
|
||||
},
|
||||
disabledDescription: {
|
||||
id: 'instance.upgrade.result.warning-disabled-description',
|
||||
defaultMessage:
|
||||
'The content was preserved on disk but disabled to avoid affecting the upgraded instance.',
|
||||
},
|
||||
legacyHeadline: {
|
||||
id: 'instance.upgrade.result.warning-legacy-headline',
|
||||
defaultMessage: 'Compatibility needs attention',
|
||||
},
|
||||
technicalDetails: {
|
||||
id: 'instance.upgrade.result.technical-details',
|
||||
defaultMessage: 'Technical details',
|
||||
},
|
||||
relativePath: { id: 'instance.upgrade.result.relative-path', defaultMessage: 'Relative path' },
|
||||
warningCode: { id: 'instance.upgrade.result.warning-code', defaultMessage: 'Warning' },
|
||||
providerIdentity: {
|
||||
id: 'instance.upgrade.result.provider-identity',
|
||||
defaultMessage: 'Provider identity',
|
||||
},
|
||||
localContent: { id: 'instance.upgrade.result.local-content', defaultMessage: 'Local content' },
|
||||
content: { id: 'instance.upgrade.result.content-kind-content', defaultMessage: 'Content' },
|
||||
mod: { id: 'instance.upgrade.result.content-kind-mod', defaultMessage: 'Mod' },
|
||||
resourcepack: {
|
||||
id: 'instance.upgrade.result.content-kind-resourcepack',
|
||||
defaultMessage: 'Resource pack',
|
||||
},
|
||||
shaderpack: {
|
||||
id: 'instance.upgrade.result.content-kind-shaderpack',
|
||||
defaultMessage: 'Shader pack',
|
||||
},
|
||||
datapack: { id: 'instance.upgrade.result.content-kind-datapack', defaultMessage: 'Data pack' },
|
||||
detailsTitle: { id: 'instance.upgrade.result.details-title', defaultMessage: 'Upgrade details' },
|
||||
search: {
|
||||
id: 'instance.upgrade.result.search-details',
|
||||
defaultMessage: 'Search upgrade details',
|
||||
},
|
||||
filters: { id: 'instance.upgrade.result.filters', defaultMessage: 'Filter upgrade details' },
|
||||
all: { id: 'instance.upgrade.result.filter-all', defaultMessage: 'All' },
|
||||
updated: { id: 'instance.upgrade.result.filter-updated', defaultMessage: 'Updated' },
|
||||
kept: { id: 'instance.upgrade.result.filter-kept', defaultMessage: 'Kept' },
|
||||
disabled: { id: 'instance.upgrade.result.filter-disabled', defaultMessage: 'Disabled' },
|
||||
dependencies: {
|
||||
id: 'instance.upgrade.result.filter-dependencies',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
showing: {
|
||||
id: 'instance.upgrade.result.showing',
|
||||
defaultMessage: 'Showing {start}–{end} of {total}',
|
||||
},
|
||||
previous: { id: 'instance.upgrade.result.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'instance.upgrade.result.next', defaultMessage: 'Next' },
|
||||
noMatches: {
|
||||
id: 'instance.upgrade.result.no-matching-items',
|
||||
defaultMessage: 'No matching upgrade items.',
|
||||
},
|
||||
unknown: { id: 'instance.upgrade.result.unknown', defaultMessage: 'Unavailable' },
|
||||
dependency: { id: 'instance.upgrade.result.dependency', defaultMessage: 'Dependency' },
|
||||
upgrade: { id: 'instance.upgrade.result.action-upgrade', defaultMessage: 'Updated' },
|
||||
keep: { id: 'instance.upgrade.result.action-keep', defaultMessage: 'Kept' },
|
||||
disable: { id: 'instance.upgrade.result.action-disable', defaultMessage: 'Disabled' },
|
||||
dependencyAdded: {
|
||||
id: 'instance.upgrade.result.dependency-added',
|
||||
defaultMessage: 'Dependency added',
|
||||
},
|
||||
dependencyUpdated: {
|
||||
id: 'instance.upgrade.result.dependency-updated-status',
|
||||
defaultMessage: 'Dependency updated',
|
||||
},
|
||||
dependencyKept: {
|
||||
id: 'instance.upgrade.result.dependency-kept',
|
||||
defaultMessage: 'Dependency kept',
|
||||
},
|
||||
dependencyRemoved: {
|
||||
id: 'instance.upgrade.result.dependency-removed-status',
|
||||
defaultMessage: 'Dependency removed',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{ result: InstanceUpgradeResult; targetVersion: string | null }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const warnings = computed(() => upgradeResultWarningRows(props.result))
|
||||
const warningSummary = computed(() => summarizeUpgradeWarnings(warnings.value))
|
||||
const warningsDefaultOpen = computed(() =>
|
||||
shouldExpandUpgradeWarningsByDefault(warnings.value.length),
|
||||
)
|
||||
const warningsOpen = ref(warningsDefaultOpen.value)
|
||||
const warningSearch = ref('')
|
||||
const warningFilter = ref<UpgradeWarningFilter>('all')
|
||||
const warningPageNumber = ref(1)
|
||||
const warningFilters = computed(() => [
|
||||
{ value: 'all' as const, label: formatMessage(messages.all) },
|
||||
{ value: 'local' as const, label: formatMessage(messages.warningFilterUnidentified) },
|
||||
{ value: 'kept' as const, label: formatMessage(messages.kept) },
|
||||
{ value: 'fallback' as const, label: formatMessage(messages.warningFilterFallback) },
|
||||
])
|
||||
const filteredWarnings = computed(() =>
|
||||
filterUpgradeWarnings(
|
||||
warnings.value,
|
||||
warningFilter.value,
|
||||
warningSearch.value,
|
||||
warningSearchFields,
|
||||
),
|
||||
)
|
||||
const warningPage = computed(() =>
|
||||
paginateUpgradeWarnings(filteredWarnings.value, warningPageNumber.value),
|
||||
)
|
||||
const warningPaginationLabel = computed(() =>
|
||||
formatMessage(messages.showing, {
|
||||
start: warningPage.value.start,
|
||||
end: warningPage.value.end,
|
||||
total: warningPage.value.total,
|
||||
}),
|
||||
)
|
||||
watch([warningSearch, warningFilter], () => {
|
||||
warningPageNumber.value = 1
|
||||
})
|
||||
watch(
|
||||
() => warningPage.value.page,
|
||||
(value) => {
|
||||
warningPageNumber.value = value
|
||||
},
|
||||
)
|
||||
const detailsOpen = ref(false)
|
||||
|
||||
const snapshotsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-content',
|
||||
props.result.sourceInstanceId,
|
||||
props.result.targetInstanceId,
|
||||
]),
|
||||
queryFn: () =>
|
||||
Promise.all(
|
||||
[...new Set([props.result.sourceInstanceId, props.result.targetInstanceId])].map((id) =>
|
||||
get_content_snapshot(id).catch(() => null),
|
||||
),
|
||||
),
|
||||
enabled: computed(() => warningsOpen.value || detailsOpen.value),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const snapshotItems = computed(
|
||||
() => snapshotsQuery.data.value?.flatMap((snapshot) => snapshot?.items ?? []) ?? [],
|
||||
)
|
||||
const allItems = computed(() => upgradeDetailItems(props.result.solution))
|
||||
const search = ref('')
|
||||
const filter = ref<UpgradeDetailFilter>('all')
|
||||
const page = ref(1)
|
||||
const filters = computed(() => [
|
||||
{ value: 'all' as const, label: formatMessage(messages.all) },
|
||||
{ value: 'updated' as const, label: formatMessage(messages.updated) },
|
||||
{ value: 'kept' as const, label: formatMessage(messages.kept) },
|
||||
{ value: 'disabled' as const, label: formatMessage(messages.disabled) },
|
||||
{ value: 'dependencies' as const, label: formatMessage(messages.dependencies) },
|
||||
])
|
||||
const filteredItems = computed(() =>
|
||||
filterUpgradeDetailItems(allItems.value, filter.value, search.value, searchFields),
|
||||
)
|
||||
const pageData = computed(() => paginateUpgradeDetailItems(filteredItems.value, page.value))
|
||||
watch([search, filter], () => {
|
||||
page.value = 1
|
||||
})
|
||||
watch(
|
||||
() => pageData.value.page,
|
||||
(value) => {
|
||||
page.value = value
|
||||
},
|
||||
)
|
||||
|
||||
const projectIdentities = computed(() =>
|
||||
detailsOpen.value ? upgradeDetailProjectIdentities(pageData.value.items) : [],
|
||||
)
|
||||
const releaseIdentities = computed(() =>
|
||||
detailsOpen.value ? upgradeDetailReleaseIdentities(pageData.value.items) : [],
|
||||
)
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-projects',
|
||||
...projectIdentities.value.map((item) => `${item.provider}:${item.projectId}`),
|
||||
]),
|
||||
queryFn: () => loadUpgradeProjectDisplayMetadata(projectIdentities.value),
|
||||
enabled: computed(() => detailsOpen.value && projectIdentities.value.length > 0),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-versions',
|
||||
...releaseIdentities.value.map(
|
||||
(item) => `${item.provider}:${item.projectId}:${item.releaseId}`,
|
||||
),
|
||||
]),
|
||||
queryFn: () => loadUpgradeVersionDisplayMetadata(releaseIdentities.value),
|
||||
enabled: computed(() => detailsOpen.value && releaseIdentities.value.length > 0),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const visibleRows = computed(() =>
|
||||
pageData.value.items.map((item) => {
|
||||
const snapshot = findSnapshot(item)
|
||||
const project =
|
||||
item.provider && item.projectId
|
||||
? projectsQuery.data.value?.get(
|
||||
upgradeProjectDisplayCacheKey(item.provider, item.projectId),
|
||||
)
|
||||
: null
|
||||
return {
|
||||
...item,
|
||||
title:
|
||||
project?.title ??
|
||||
snapshot?.content?.project.title ??
|
||||
snapshot?.content?.file_name ??
|
||||
filename(snapshot?.expectedRelativePath) ??
|
||||
item.projectId ??
|
||||
item.contentId ??
|
||||
formatMessage(messages.unknown),
|
||||
context:
|
||||
item.kind === 'dependency'
|
||||
? formatMessage(messages.dependency)
|
||||
: (snapshot?.expectedRelativePath ??
|
||||
item.provider ??
|
||||
formatMessage(messages.localContent)),
|
||||
path: upgradeProjectPath(item.provider, item.projectId),
|
||||
current: releaseLabel(item, item.currentReleaseId) ?? formatMessage(messages.unknown),
|
||||
target: releaseLabel(item, item.targetReleaseId),
|
||||
actionLabel: actionLabel(item),
|
||||
badgeColor:
|
||||
item.kind === 'selection' && item.action === 'disable'
|
||||
? ('gray' as const)
|
||||
: item.kind === 'selection' && item.action === 'keep'
|
||||
? ('blue' as const)
|
||||
: ('green' as const),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const paginationLabel = computed(() =>
|
||||
formatMessage(messages.showing, {
|
||||
start: pageData.value.start,
|
||||
end: pageData.value.end,
|
||||
total: pageData.value.total,
|
||||
}),
|
||||
)
|
||||
|
||||
function findSnapshot(item: UpgradeDetailItem) {
|
||||
return (
|
||||
snapshotItems.value.find((snapshot) => snapshot.entryId === item.contentId) ??
|
||||
snapshotItems.value.find(
|
||||
(snapshot) =>
|
||||
snapshot.provider === item.provider && snapshot.providerProjectId === item.projectId,
|
||||
)
|
||||
)
|
||||
}
|
||||
function searchFields(item: UpgradeDetailItem) {
|
||||
const snapshot = findSnapshot(item)
|
||||
return [
|
||||
snapshot?.content?.project.title,
|
||||
snapshot?.content?.file_name,
|
||||
snapshot?.content?.version?.version_number,
|
||||
snapshot?.expectedRelativePath,
|
||||
item.contentId,
|
||||
item.provider,
|
||||
item.projectId,
|
||||
item.currentReleaseId,
|
||||
item.targetReleaseId,
|
||||
]
|
||||
}
|
||||
function releaseLabel(item: UpgradeDetailItem, releaseId: string | null) {
|
||||
return releaseId
|
||||
? upgradeVersionDisplayLabel(versionsQuery.data.value, {
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
releaseId,
|
||||
})
|
||||
: null
|
||||
}
|
||||
function actionLabel(item: UpgradeDetailItem) {
|
||||
if (item.kind === 'selection')
|
||||
return formatMessage(messages[item.action as 'upgrade' | 'keep' | 'disable'])
|
||||
return formatMessage(
|
||||
item.action === 'add'
|
||||
? messages.dependencyAdded
|
||||
: item.action === 'upgrade'
|
||||
? messages.dependencyUpdated
|
||||
: item.action === 'remove'
|
||||
? messages.dependencyRemoved
|
||||
: messages.dependencyKept,
|
||||
)
|
||||
}
|
||||
function filename(path: string | null | undefined) {
|
||||
return path?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) ?? null
|
||||
}
|
||||
function warningIdentity(warning: UpgradeWarningRow) {
|
||||
const snapshot = snapshotItems.value.find(
|
||||
(item) =>
|
||||
item.entryId === warning.contentId || item.expectedRelativePath === warning.relativePath,
|
||||
)
|
||||
return (
|
||||
snapshot?.content?.project.title ??
|
||||
snapshot?.content?.file_name ??
|
||||
upgradeWarningDisplayName(warning) ??
|
||||
formatMessage(messages.unknown)
|
||||
)
|
||||
}
|
||||
function warningContext(warning: UpgradeWarningRow) {
|
||||
return `${formatMessage(messages[upgradeWarningContentKind(warning)])} · ${warning.provider ?? formatMessage(messages.localContent)}`
|
||||
}
|
||||
function warningSearchFields(warning: UpgradeWarningRow) {
|
||||
return [
|
||||
warningIdentity(warning),
|
||||
warning.relativePath,
|
||||
warning.code,
|
||||
warning.provider,
|
||||
warning.projectId,
|
||||
formatMessage(
|
||||
upgradeWarningCategory(warning) === 'local'
|
||||
? messages.warningFilterUnidentified
|
||||
: upgradeWarningCategory(warning) === 'kept'
|
||||
? messages.kept
|
||||
: messages.warningFilterFallback,
|
||||
),
|
||||
]
|
||||
}
|
||||
function summaryLabel(category: UpgradeWarningCategory) {
|
||||
return formatMessage(
|
||||
category === 'local'
|
||||
? messages.summaryLocal
|
||||
: category === 'kept'
|
||||
? messages.summaryKept
|
||||
: messages.summaryFallback,
|
||||
{ count: warningSummary.value[category] },
|
||||
)
|
||||
}
|
||||
function warningHeadline(warning: UpgradeWarningRow) {
|
||||
if (warning.legacyMessage) return formatMessage(messages.legacyHeadline)
|
||||
const action = warningAction(warning)
|
||||
if (action === 'disable') return formatMessage(messages.disabledHeadline)
|
||||
if (action === 'upgrade' && warning.code !== 'prerelease_only') {
|
||||
return formatMessage(messages.resolvedHeadline)
|
||||
}
|
||||
if (warning.code === 'prerelease_only' && action !== 'upgrade') {
|
||||
return formatMessage(messages.keptHeadline)
|
||||
}
|
||||
if (warning.code === 'unidentified') return formatMessage(messages.unidentifiedHeadline)
|
||||
if (warning.code === 'unsupported_content_type')
|
||||
return formatMessage(messages.unsupportedHeadline)
|
||||
if (warning.code === 'keep_incompatible' || warning.code === 'no_compatible_release')
|
||||
return formatMessage(messages.keptHeadline)
|
||||
if (warning.code === 'prerelease_only') return formatMessage(messages.prereleaseHeadline)
|
||||
if (warning.code?.includes('shader')) return formatMessage(messages.shaderHeadline)
|
||||
if (warning.code === 'dependency_conflict') return formatMessage(messages.conflictHeadline)
|
||||
if (warning.code === 'missing_required_dependency') {
|
||||
return formatMessage(messages.missingDependencyHeadline)
|
||||
}
|
||||
if (warning.code === 'incompatible_dependency') {
|
||||
return formatMessage(messages.incompatibleDependencyHeadline)
|
||||
}
|
||||
if (warning.code === 'search_limit_reached') return formatMessage(messages.searchLimitHeadline)
|
||||
return formatMessage(messages.dependencyHeadline)
|
||||
}
|
||||
function warningDescription(warning: UpgradeWarningRow) {
|
||||
if (warning.legacyMessage) return warning.legacyMessage
|
||||
const action = warningAction(warning)
|
||||
if (action === 'disable') return formatMessage(messages.disabledDescription)
|
||||
if (action === 'upgrade' && warning.code !== 'prerelease_only') {
|
||||
return formatMessage(messages.resolvedDescription)
|
||||
}
|
||||
if (warning.code === 'prerelease_only' && action !== 'upgrade') {
|
||||
return formatMessage(messages.keptDescription)
|
||||
}
|
||||
if (warning.code === 'unidentified')
|
||||
return formatMessage(messages.unidentifiedDescription, {
|
||||
targetVersion: props.targetVersion ?? formatMessage(messages.unknown),
|
||||
})
|
||||
if (warning.code === 'unsupported_content_type')
|
||||
return formatMessage(messages.unsupportedDescription)
|
||||
if (warning.code === 'keep_incompatible' || warning.code === 'no_compatible_release')
|
||||
return formatMessage(messages.keptDescription)
|
||||
if (warning.code === 'prerelease_only') return formatMessage(messages.prereleaseDescription)
|
||||
if (warning.code?.includes('shader')) return formatMessage(messages.shaderDescription)
|
||||
if (warning.code === 'dependency_conflict') return formatMessage(messages.conflictDescription)
|
||||
if (warning.code === 'missing_required_dependency') {
|
||||
return formatMessage(messages.missingDependencyDescription)
|
||||
}
|
||||
if (warning.code === 'incompatible_dependency') {
|
||||
return formatMessage(messages.incompatibleDependencyDescription)
|
||||
}
|
||||
if (warning.code === 'search_limit_reached') return formatMessage(messages.searchLimitDescription)
|
||||
return formatMessage(messages.dependencyDescription)
|
||||
}
|
||||
|
||||
function warningAction(warning: UpgradeWarningRow) {
|
||||
return props.result.solution.selections.find(
|
||||
(selection) => selection.contentId === warning.contentId,
|
||||
)?.action
|
||||
}
|
||||
function hasTechnicalDetails(warning: UpgradeWarningRow) {
|
||||
return Boolean(warning.relativePath || warning.code || warning.provider || warning.projectId)
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-6 py-2">
|
||||
<header class="flex items-start gap-3">
|
||||
<CheckCircleIcon class="mt-0.5 size-8 shrink-0 text-green" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-0 mt-1 text-secondary">
|
||||
{{
|
||||
formatMessage(
|
||||
mode === 'copy_and_upgrade' ? messages.copyDescription : messages.directDescription,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.environment) }}
|
||||
</h3>
|
||||
<div class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span class="text-secondary">{{ formatMessage(messages.minecraft) }}</span>
|
||||
<strong
|
||||
>{{ sourceEnvironment?.gameVersion ?? formatMessage(messages.unknown) }}
|
||||
<span aria-hidden="true">→</span>
|
||||
{{ targetEnvironment?.gameVersion ?? formatMessage(messages.unknown) }}</strong
|
||||
>
|
||||
<span class="text-secondary">{{ formatMessage(messages.loader) }}</span>
|
||||
<strong
|
||||
>{{ loaderLabel(sourceEnvironment) }} <span aria-hidden="true">→</span>
|
||||
{{ loaderLabel(actualTargetEnvironment) }}</strong
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.metrics) }}
|
||||
</h3>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div v-for="metric in metrics" :key="metric.label">
|
||||
<div class="text-xl font-semibold text-contrast">{{ metric.value }}</div>
|
||||
<div class="text-xs text-secondary">{{ metric.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="brand"
|
||||
><button @click="router.push(`/instance/${encodeURIComponent(result.targetInstanceId)}`)">
|
||||
<ExternalIcon />{{ formatMessage(messages.openUpgraded) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled v-if="mode === 'copy_and_upgrade'" type="outlined"
|
||||
><button @click="router.push(`/instance/${encodeURIComponent(result.sourceInstanceId)}`)">
|
||||
<ExternalIcon />{{ formatMessage(messages.openOriginal) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled v-if="result.backupInstanceId" type="outlined"
|
||||
><button @click="router.push(`/instance/${encodeURIComponent(result.backupInstanceId!)}`)">
|
||||
<FolderOpenIcon />{{ formatMessage(messages.openBackup) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
|
||||
<Card v-if="result.backupInstanceId" class="!m-0 p-4">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.backupTitle) }}
|
||||
</h3>
|
||||
<p class="mb-3 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.backupDescription) }}
|
||||
</p>
|
||||
</Card>
|
||||
<Admonition
|
||||
v-else-if="mode === 'direct'"
|
||||
type="info"
|
||||
:header="formatMessage(messages.noBackupTitle)"
|
||||
>{{ formatMessage(messages.noBackupDescription) }}</Admonition
|
||||
>
|
||||
|
||||
<Admonition
|
||||
v-if="result.externalChanges.length"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.externalTitle)"
|
||||
>
|
||||
<p class="mb-2">{{ formatMessage(messages.externalDescription) }}</p>
|
||||
<ul class="m-0 list-disc pl-5">
|
||||
<li v-for="change in result.externalChanges" :key="`${change.kind}:${change.relativePath}`">
|
||||
<code>{{ change.relativePath }}</code> · {{ externalChangeLabel(change.kind) }}
|
||||
</li>
|
||||
</ul>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="result.skippedDueToExternalConflict.length"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.skippedTitle)"
|
||||
>
|
||||
<p class="mb-2">{{ formatMessage(messages.skippedDescription) }}</p>
|
||||
<ul class="m-0 list-disc pl-5">
|
||||
<li v-for="path in result.skippedDueToExternalConflict" :key="path">
|
||||
<code>{{ path }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</Admonition>
|
||||
<UpgradeResultCollections
|
||||
:result="result"
|
||||
:target-version="targetEnvironment?.gameVersion ?? null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, ExternalIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import type {
|
||||
InstanceUpgradeExternalChangeKind,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
|
||||
import { summarizeUpgradeResult, upgradeResultMode } from './result'
|
||||
import UpgradeResultCollections from './UpgradeResultCollections.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'instance.upgrade.result.title', defaultMessage: 'Upgrade complete' },
|
||||
directDescription: {
|
||||
id: 'instance.upgrade.result.direct-description',
|
||||
defaultMessage: 'This instance was upgraded successfully.',
|
||||
},
|
||||
copyDescription: {
|
||||
id: 'instance.upgrade.result.copy-description',
|
||||
defaultMessage:
|
||||
'An upgraded copy was created successfully. The original shared instance was left unchanged.',
|
||||
},
|
||||
environment: { id: 'instance.upgrade.result.environment', defaultMessage: 'Environment' },
|
||||
minecraft: { id: 'instance.upgrade.result.minecraft', defaultMessage: 'Minecraft' },
|
||||
loader: { id: 'instance.upgrade.result.loader', defaultMessage: 'Loader' },
|
||||
unknown: { id: 'instance.upgrade.result.unknown', defaultMessage: 'Unavailable' },
|
||||
automatic: { id: 'instance.upgrade.result.automatic', defaultMessage: 'Automatic' },
|
||||
metrics: { id: 'instance.upgrade.result.metrics', defaultMessage: 'Outcome' },
|
||||
updated: { id: 'instance.upgrade.result.updated', defaultMessage: 'Updated' },
|
||||
kept: { id: 'instance.upgrade.result.kept', defaultMessage: 'Kept' },
|
||||
disabled: { id: 'instance.upgrade.result.disabled', defaultMessage: 'Disabled' },
|
||||
added: { id: 'instance.upgrade.result.dependencies-added', defaultMessage: 'Dependencies added' },
|
||||
dependencyUpdated: {
|
||||
id: 'instance.upgrade.result.dependencies-updated',
|
||||
defaultMessage: 'Dependencies updated',
|
||||
},
|
||||
removed: {
|
||||
id: 'instance.upgrade.result.dependencies-removed',
|
||||
defaultMessage: 'Dependencies removed',
|
||||
},
|
||||
openUpgraded: {
|
||||
id: 'instance.upgrade.result.open-upgraded',
|
||||
defaultMessage: 'Open upgraded instance',
|
||||
},
|
||||
openOriginal: {
|
||||
id: 'instance.upgrade.result.open-original',
|
||||
defaultMessage: 'Open original instance',
|
||||
},
|
||||
backupTitle: { id: 'instance.upgrade.result.backup-title', defaultMessage: 'Backup created' },
|
||||
backupDescription: {
|
||||
id: 'instance.upgrade.result.backup-description',
|
||||
defaultMessage:
|
||||
'A complete pre-upgrade copy was created separately from automatic technical rollback.',
|
||||
},
|
||||
openBackup: { id: 'instance.upgrade.result.open-backup', defaultMessage: 'Open backup' },
|
||||
noBackupTitle: {
|
||||
id: 'instance.upgrade.result.no-backup-title',
|
||||
defaultMessage: 'No complete backup was created',
|
||||
},
|
||||
noBackupDescription: {
|
||||
id: 'instance.upgrade.result.no-backup-description',
|
||||
defaultMessage:
|
||||
'Automatic technical rollback protected this operation while it was running; it is not a permanent backup.',
|
||||
},
|
||||
externalTitle: {
|
||||
id: 'instance.upgrade.result.external-title',
|
||||
defaultMessage: 'Changes detected while upgrading',
|
||||
},
|
||||
externalDescription: {
|
||||
id: 'instance.upgrade.result.external-description',
|
||||
defaultMessage:
|
||||
'Files changed outside the launcher were detected, and user changes were given priority where applicable.',
|
||||
},
|
||||
skippedTitle: {
|
||||
id: 'instance.upgrade.result.skipped-title',
|
||||
defaultMessage: 'Some planned changes were skipped',
|
||||
},
|
||||
skippedDescription: {
|
||||
id: 'instance.upgrade.result.skipped-description',
|
||||
defaultMessage: 'These files changed while upgrading, so the user changes were preserved.',
|
||||
},
|
||||
warningsTitle: {
|
||||
id: 'instance.upgrade.result.warnings-title',
|
||||
defaultMessage: 'Compatibility warnings',
|
||||
},
|
||||
warningPrereleaseOnly: {
|
||||
id: 'instance.upgrade.warning.prerelease-only',
|
||||
defaultMessage: '{path} only has prerelease builds for the target environment.',
|
||||
},
|
||||
warningUnidentified: {
|
||||
id: 'instance.upgrade.warning.unidentified',
|
||||
defaultMessage: '{path} could not be identified and was preserved unchanged.',
|
||||
},
|
||||
warningDependencyConflict: {
|
||||
id: 'instance.upgrade.warning.dependency-conflict',
|
||||
defaultMessage: '{path} has conflicting dependency requirements.',
|
||||
},
|
||||
warningMissingDependency: {
|
||||
id: 'instance.upgrade.warning.missing-required-dependency',
|
||||
defaultMessage: '{path} requires a dependency that could not be resolved.',
|
||||
},
|
||||
warningIncompatibleDependency: {
|
||||
id: 'instance.upgrade.warning.incompatible-dependency',
|
||||
defaultMessage: '{path} has an incompatible dependency.',
|
||||
},
|
||||
warningUnsupportedType: {
|
||||
id: 'instance.upgrade.warning.unsupported-content-type',
|
||||
defaultMessage: '{path} uses a content type that cannot be upgraded automatically.',
|
||||
},
|
||||
warningNoRelease: {
|
||||
id: 'instance.upgrade.warning.no-compatible-release',
|
||||
defaultMessage: '{path} has no compatible release for the target environment.',
|
||||
},
|
||||
warningNoShaderRuntime: {
|
||||
id: 'instance.upgrade.warning.no-compatible-shader-runtime',
|
||||
defaultMessage: '{path} has no release compatible with the target shader runtime.',
|
||||
},
|
||||
warningShaderMissing: {
|
||||
id: 'instance.upgrade.warning.shader-runtime-missing',
|
||||
defaultMessage: '{path} was preserved because no target shader runtime is configured.',
|
||||
},
|
||||
warningShaderUnknown: {
|
||||
id: 'instance.upgrade.warning.shader-runtime-unknown',
|
||||
defaultMessage: '{path} was preserved because the target shader runtime is unknown.',
|
||||
},
|
||||
warningSearchLimit: {
|
||||
id: 'instance.upgrade.warning.search-limit-reached',
|
||||
defaultMessage: '{path} could not be resolved within the bounded compatibility search.',
|
||||
},
|
||||
warningKeepIncompatible: {
|
||||
id: 'instance.upgrade.warning.keep-incompatible',
|
||||
defaultMessage: '{path} was kept unchanged and may be incompatible with the upgraded instance.',
|
||||
},
|
||||
detailsTitle: { id: 'instance.upgrade.result.details-title', defaultMessage: 'Upgrade details' },
|
||||
add: { id: 'instance.upgrade.result.action-add', defaultMessage: 'Added' },
|
||||
upgrade: { id: 'instance.upgrade.result.action-upgrade', defaultMessage: 'Updated' },
|
||||
keep: { id: 'instance.upgrade.result.action-keep', defaultMessage: 'Kept' },
|
||||
disable: { id: 'instance.upgrade.result.action-disable', defaultMessage: 'Disabled' },
|
||||
remove: { id: 'instance.upgrade.result.action-remove', defaultMessage: 'Removed' },
|
||||
changeAdded: { id: 'instance.upgrade.result.change-added', defaultMessage: 'Added' },
|
||||
changeRemoved: { id: 'instance.upgrade.result.change-removed', defaultMessage: 'Removed' },
|
||||
changeModified: { id: 'instance.upgrade.result.change-modified', defaultMessage: 'Modified' },
|
||||
})
|
||||
|
||||
const props = defineProps<{ result: import('@/helpers/instance-upgrade').InstanceUpgradeResult }>()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const result = computed(() => props.result)
|
||||
const mode = computed(() => upgradeResultMode(result.value))
|
||||
const targetEnvironment = computed(() => result.value.targetEnvironment ?? null)
|
||||
const sourceEnvironment = computed(() => result.value.sourceEnvironment ?? null)
|
||||
const relatedInstancesQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'instance-upgrade',
|
||||
'result-instances',
|
||||
result.value.sourceInstanceId,
|
||||
result.value.targetInstanceId,
|
||||
result.value.backupInstanceId,
|
||||
]),
|
||||
queryFn: () =>
|
||||
getInstances([
|
||||
result.value.sourceInstanceId,
|
||||
result.value.targetInstanceId,
|
||||
...(result.value.backupInstanceId ? [result.value.backupInstanceId] : []),
|
||||
]).catch(() => []),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const targetInstance = computed(
|
||||
() =>
|
||||
relatedInstancesQuery.data.value?.find(
|
||||
(instance) => instance.id === result.value.targetInstanceId,
|
||||
) ?? null,
|
||||
)
|
||||
const actualTargetEnvironment = computed<InstanceUpgradeTargetEnvironment | null>(() =>
|
||||
targetInstance.value
|
||||
? {
|
||||
gameVersion: targetInstance.value.game_version,
|
||||
modLoader: targetInstance.value.loader,
|
||||
modLoaderVersion:
|
||||
targetInstance.value.loader_version ?? targetEnvironment.value?.modLoaderVersion ?? null,
|
||||
shaderRuntime: targetEnvironment.value?.shaderRuntime ?? 'unknown',
|
||||
}
|
||||
: targetEnvironment.value,
|
||||
)
|
||||
const summary = computed(() => summarizeUpgradeResult(result.value.solution))
|
||||
const metrics = computed(() => [
|
||||
{ label: formatMessage(messages.updated), value: summary.value.updated },
|
||||
{ label: formatMessage(messages.kept), value: summary.value.kept },
|
||||
{ label: formatMessage(messages.disabled), value: summary.value.disabled },
|
||||
{ label: formatMessage(messages.added), value: summary.value.dependencyAdded },
|
||||
{ label: formatMessage(messages.dependencyUpdated), value: summary.value.dependencyUpdated },
|
||||
{ label: formatMessage(messages.removed), value: summary.value.dependencyRemoved },
|
||||
])
|
||||
|
||||
function loaderLabel(environment: InstanceUpgradeTargetEnvironment | null) {
|
||||
if (!environment) return formatMessage(messages.unknown)
|
||||
const label = formatLoaderLabel(environment.modLoader)
|
||||
return environment.modLoaderVersion
|
||||
? `${label} ${environment.modLoaderVersion}`
|
||||
: `${label} (${formatMessage(messages.automatic)})`
|
||||
}
|
||||
function externalChangeLabel(kind: InstanceUpgradeExternalChangeKind) {
|
||||
return formatMessage(
|
||||
messages[
|
||||
kind === 'added' ? 'changeAdded' : kind === 'removed' ? 'changeRemoved' : 'changeModified'
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
122
apps/app-frontend/src/pages/instance/upgrade/UpgradeShell.vue
Normal file
122
apps/app-frontend/src/pages/instance/upgrade/UpgradeShell.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full" :class="wideCompatibilityLayout ? 'max-w-[96rem]' : 'max-w-5xl'">
|
||||
<RouterView v-if="instanceMatchesRoute" />
|
||||
</div>
|
||||
<UpgradeFlowFloatingBar v-if="instanceMatchesRoute" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, toRef, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { parkUpgradeFlow, restoreUpgradeFlow } from '@/helpers/upgrade-return-state'
|
||||
|
||||
import {
|
||||
attachUpgradeJobToFlow,
|
||||
isUpgradeRouteAvailable,
|
||||
isUpgradeRouteRecoveryPending,
|
||||
provideInstanceUpgradeFlow,
|
||||
type UpgradeRouteRequirement,
|
||||
} from './flow'
|
||||
import { isRecoverableUpgradeStatus, recoverInstanceUpgradeJob } from './install-job'
|
||||
import UpgradeFlowFloatingBar from './UpgradeFlowFloatingBar.vue'
|
||||
|
||||
const props = defineProps<{ instance: GameInstance }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const flow = provideInstanceUpgradeFlow(toRef(props, 'instance'))
|
||||
const routeInstanceId = computed(() =>
|
||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
|
||||
)
|
||||
const instanceMatchesRoute = computed(() => routeInstanceId.value === props.instance.id)
|
||||
const wideCompatibilityLayout = computed(() => route.path.endsWith('/upgrade/compatibility'))
|
||||
const restoredSnapshot = restoreUpgradeFlow(props.instance.id, route.fullPath, flow.hydrate)
|
||||
|
||||
async function recoverUpgradeJob() {
|
||||
const instanceId = props.instance.id
|
||||
const requirement = route.meta.upgradeRequirement as UpgradeRouteRequirement | undefined
|
||||
if (requirement === 'result') {
|
||||
flow.setJobRecoveryState('ready')
|
||||
return
|
||||
}
|
||||
flow.setJobRecoveryState('loading')
|
||||
try {
|
||||
const job = await recoverInstanceUpgradeJob(instanceId, {
|
||||
knownJobId: flow.activeJobId.value,
|
||||
continuation: requirement === 'job',
|
||||
})
|
||||
if (props.instance.id !== instanceId || !job) return
|
||||
const downloadsLocation = attachUpgradeJobToFlow(flow, job)
|
||||
if (isRecoverableUpgradeStatus(job.status) && requirement !== 'job') {
|
||||
await router.replace(downloadsLocation)
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
if (props.instance.id === instanceId) flow.setJobRecoveryState('ready')
|
||||
}
|
||||
}
|
||||
|
||||
void recoverUpgradeJob()
|
||||
|
||||
onMounted(async () => {
|
||||
if (restoredSnapshot?.scrollTop === undefined) return
|
||||
await nextTick()
|
||||
const viewport = document.querySelector('.app-viewport')
|
||||
if (viewport) viewport.scrollTop = restoredSnapshot.scrollTop
|
||||
})
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (to.path.startsWith('/project/')) {
|
||||
parkUpgradeFlow({
|
||||
instanceId: props.instance.id,
|
||||
returnFullPath: route.fullPath,
|
||||
targetEnvironment: flow.targetEnvironment.value,
|
||||
plan: flow.plan.value,
|
||||
createFullBackup: flow.createFullBackup.value,
|
||||
directFullBackupPreference: flow.directFullBackupPreference.value,
|
||||
sharedUpgradeMode: flow.sharedUpgradeMode.value,
|
||||
activeJobId: flow.activeJobId.value,
|
||||
result: flow.result.value,
|
||||
initialBlockingPlanId: flow.initialBlockingPlanId.value,
|
||||
initialBlockingIssues: flow.initialBlockingIssues.value,
|
||||
customizeActiveStrategy: flow.customizeActiveStrategy.value,
|
||||
scrollTop: document.querySelector('.app-viewport')?.scrollTop,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function safeEntryPath(instanceId: string) {
|
||||
return `/instance/${encodeURIComponent(instanceId)}/upgrade`
|
||||
}
|
||||
|
||||
function requirementFallback(instanceId: string, requirement: UpgradeRouteRequirement | undefined) {
|
||||
if ((requirement === 'unblocked-plan' || requirement === 'selection') && flow.plan.value) {
|
||||
return `${safeEntryPath(instanceId)}/compatibility`
|
||||
}
|
||||
return safeEntryPath(instanceId)
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => route.fullPath,
|
||||
() => props.instance.id,
|
||||
flow.activeJobId,
|
||||
flow.jobRecoveryState,
|
||||
flow.plan,
|
||||
flow.result,
|
||||
],
|
||||
async () => {
|
||||
if (!instanceMatchesRoute.value) return
|
||||
const requirement = route.meta.upgradeRequirement as UpgradeRouteRequirement | undefined
|
||||
if (requirement === 'result') return
|
||||
if (isUpgradeRouteRecoveryPending(requirement, flow)) return
|
||||
if (requirement === 'job' && route.name === 'InstanceUpgradeProgress') return
|
||||
if (!isUpgradeRouteAvailable(requirement, flow)) {
|
||||
await router.replace(requirementFallback(props.instance.id, requirement))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<section :data-instance-id="flow.instanceId.value" class="flex flex-col gap-2 py-2">
|
||||
<h2 class="m-0 text-xl font-semibold text-contrast">{{ title }}</h2>
|
||||
<p class="m-0 max-w-2xl text-secondary">{{ description }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInstanceUpgradeFlow } from './flow'
|
||||
|
||||
defineProps<{ title: string; description: string }>()
|
||||
const flow = useInstanceUpgradeFlow()
|
||||
</script>
|
||||
@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<span
|
||||
ref="trigger"
|
||||
class="relative inline-flex max-w-full"
|
||||
tabindex="0"
|
||||
@mouseenter="setOwnership('triggerHovered', true)"
|
||||
@mouseleave="setOwnership('triggerHovered', false)"
|
||||
@focus="setOwnership('triggerFocused', true)"
|
||||
@blur="setOwnership('triggerFocused', false)"
|
||||
>
|
||||
<span class="cursor-help underline decoration-dotted underline-offset-2">{{ label }}</span>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="popup"
|
||||
:style="popupStyle"
|
||||
class="fixed z-[200] flex max-h-[min(28rem,calc(100dvh-2rem))] w-96 max-w-[calc(100vw-2rem)] flex-col rounded-lg border border-solid border-surface-5 p-3 text-left shadow-xl"
|
||||
@mouseenter="setOwnership('popupHovered', true)"
|
||||
@mouseleave="setOwnership('popupHovered', false)"
|
||||
@focusin="setOwnership('popupFocused', true)"
|
||||
@focusout="setOwnership('popupFocused', false)"
|
||||
>
|
||||
<span v-if="loading" class="text-sm text-secondary">{{
|
||||
formatMessage(messages.loading)
|
||||
}}</span>
|
||||
<template v-else-if="metadata">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<strong class="block truncate text-sm text-contrast">{{ metadata.version }}</strong>
|
||||
<span v-if="metadata.channel" class="mt-1 block text-xs uppercase text-secondary">{{
|
||||
metadata.channel
|
||||
}}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="metadata.changelog" type="transparent" size="small">
|
||||
<button :disabled="translationLoading" @click="toggleTranslation">
|
||||
<SpinnerIcon v-if="translationLoading" class="animate-spin" aria-hidden="true" />
|
||||
{{ formatMessage(showTranslation ? messages.showOriginal : messages.translate) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p v-if="translationError" class="mb-0 mt-2 text-sm text-red">{{ translationError }}</p>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="metadata.changelog"
|
||||
class="markdown-body mt-2 min-h-0 overflow-y-auto text-sm text-secondary"
|
||||
@click="openExternalLink"
|
||||
v-html="renderedChangelog"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<span v-else class="mt-2 block text-sm text-secondary">{{
|
||||
formatMessage(messages.empty)
|
||||
}}</span>
|
||||
</template>
|
||||
<span v-else class="text-sm text-secondary">{{ formatMessage(messages.unavailable) }}</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { renderHighlightedString } from '@modrinth/utils'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getTranslationErrorKind,
|
||||
getTranslationSettings,
|
||||
prepareDescription,
|
||||
renderTranslatedDescription,
|
||||
translateInBatches,
|
||||
validateTranslatedDescription,
|
||||
} from '@/helpers/translation'
|
||||
import {
|
||||
getUpgradeChangelogTranslation,
|
||||
setUpgradeChangelogTranslation,
|
||||
shouldUpgradeChangelogStayOpen,
|
||||
upgradeChangelogTranslationCacheKey,
|
||||
upgradeExternalChangelogUrl,
|
||||
} from '@/helpers/upgrade-changelog'
|
||||
import { loadUpgradeVersionMetadata } from '@/helpers/upgrade-version-metadata'
|
||||
import i18n from '@/i18n.config'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
provider: string | null
|
||||
projectId: string | null
|
||||
releaseId: string | null
|
||||
}>()
|
||||
const messages = defineMessages({
|
||||
loading: { id: 'instance.upgrade.changelog.loading', defaultMessage: 'Loading release details…' },
|
||||
empty: {
|
||||
id: 'instance.upgrade.changelog.empty',
|
||||
defaultMessage: 'No changelog was provided for this version.',
|
||||
},
|
||||
unavailable: {
|
||||
id: 'instance.upgrade.changelog.unavailable',
|
||||
defaultMessage: 'Release details unavailable.',
|
||||
},
|
||||
translate: { id: 'instance.upgrade.changelog.translate', defaultMessage: 'Translate' },
|
||||
showOriginal: { id: 'instance.upgrade.changelog.show-original', defaultMessage: 'Show original' },
|
||||
translationRateLimited: {
|
||||
id: 'instance.upgrade.changelog.translation.rate-limited',
|
||||
defaultMessage: 'Translation is temporarily rate limited.',
|
||||
},
|
||||
translationAuthentication: {
|
||||
id: 'instance.upgrade.changelog.translation.authentication',
|
||||
defaultMessage: 'Translation provider authentication failed.',
|
||||
},
|
||||
translationTooLong: {
|
||||
id: 'instance.upgrade.changelog.translation.too-long',
|
||||
defaultMessage: 'This changelog is too long to translate.',
|
||||
},
|
||||
translationNetwork: {
|
||||
id: 'instance.upgrade.changelog.translation.network',
|
||||
defaultMessage: 'Translation network request failed.',
|
||||
},
|
||||
translationFailed: {
|
||||
id: 'instance.upgrade.changelog.translation.failed',
|
||||
defaultMessage: 'Changelog translation failed.',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
const visible = ref(false)
|
||||
const trigger = ref<HTMLElement | null>(null)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
const popupStyle = ref<Record<string, string>>({ backgroundColor: 'var(--color-tooltip-bg)' })
|
||||
const loading = ref(false)
|
||||
const metadata = ref<Awaited<ReturnType<typeof loadUpgradeVersionMetadata>> | null>(null)
|
||||
const translationLoading = ref(false)
|
||||
const translationError = ref<string | null>(null)
|
||||
const translatedChangelog = ref<string | null>(null)
|
||||
const showTranslation = ref(false)
|
||||
const ownership = ref({
|
||||
triggerHovered: false,
|
||||
triggerFocused: false,
|
||||
popupHovered: false,
|
||||
popupFocused: false,
|
||||
})
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let loaded = false
|
||||
|
||||
const renderedChangelog = computed(() => {
|
||||
if (showTranslation.value && translatedChangelog.value) return translatedChangelog.value
|
||||
return renderHighlightedString(metadata.value?.changelog ?? '')
|
||||
})
|
||||
|
||||
function cancelClose() {
|
||||
if (closeTimer) clearTimeout(closeTimer)
|
||||
}
|
||||
|
||||
function positionPopup() {
|
||||
if (!trigger.value || !popup.value) return
|
||||
const anchor = trigger.value.getBoundingClientRect()
|
||||
if (anchor.bottom < 0 || anchor.top > window.innerHeight) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
const popupRect = popup.value.getBoundingClientRect()
|
||||
const gap = 8
|
||||
const margin = 8
|
||||
const placeAbove =
|
||||
window.innerHeight - anchor.bottom < popupRect.height + gap &&
|
||||
anchor.top > popupRect.height + gap
|
||||
const desiredTop = placeAbove ? anchor.top - popupRect.height - gap : anchor.bottom + gap
|
||||
popupStyle.value = {
|
||||
backgroundColor: 'var(--color-tooltip-bg)',
|
||||
left: `${Math.max(margin, Math.min(anchor.left, window.innerWidth - popupRect.width - margin))}px`,
|
||||
top: `${Math.max(margin, Math.min(desiredTop, window.innerHeight - popupRect.height - margin))}px`,
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
cancelClose()
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
if (loaded || !props.provider || !props.projectId || !props.releaseId) return
|
||||
loaded = true
|
||||
loading.value = true
|
||||
try {
|
||||
metadata.value = await loadUpgradeVersionMetadata(
|
||||
props.provider,
|
||||
props.projectId,
|
||||
props.releaseId,
|
||||
)
|
||||
} catch {
|
||||
metadata.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
}
|
||||
}
|
||||
|
||||
function setOwnership(key: keyof typeof ownership.value, active: boolean) {
|
||||
ownership.value = { ...ownership.value, [key]: active }
|
||||
if (shouldUpgradeChangelogStayOpen(ownership.value)) {
|
||||
void open()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function translationFailureMessage(error: unknown) {
|
||||
return formatMessage(
|
||||
{
|
||||
'rate-limited': messages.translationRateLimited,
|
||||
authentication: messages.translationAuthentication,
|
||||
'content-too-long': messages.translationTooLong,
|
||||
network: messages.translationNetwork,
|
||||
provider: messages.translationFailed,
|
||||
}[getTranslationErrorKind(error)],
|
||||
)
|
||||
}
|
||||
|
||||
async function toggleTranslation() {
|
||||
if (showTranslation.value) {
|
||||
showTranslation.value = false
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
return
|
||||
}
|
||||
if (!metadata.value?.changelog || !props.provider || !props.projectId || !props.releaseId) return
|
||||
translationLoading.value = true
|
||||
translationError.value = null
|
||||
try {
|
||||
const settings = await getTranslationSettings()
|
||||
const targetLanguage = settings.target_language || i18n.global.locale.value || 'en-US'
|
||||
const key = upgradeChangelogTranslationCacheKey(
|
||||
props.provider,
|
||||
props.projectId,
|
||||
props.releaseId,
|
||||
targetLanguage,
|
||||
)
|
||||
const cached = getUpgradeChangelogTranslation(key)
|
||||
if (cached) {
|
||||
translatedChangelog.value = cached
|
||||
} else {
|
||||
const prepared = prepareDescription(metadata.value.changelog)
|
||||
const accumulated: Record<string, string> = {}
|
||||
await translateInBatches(
|
||||
{
|
||||
source_language: 'auto',
|
||||
target_language: targetLanguage,
|
||||
segments: prepared.segments,
|
||||
context: { title: metadata.value.version, description: '' },
|
||||
},
|
||||
(response) => {
|
||||
for (const segment of response.segments) accumulated[segment.id] = segment.text
|
||||
},
|
||||
)
|
||||
validateTranslatedDescription(prepared, accumulated)
|
||||
const translated = renderTranslatedDescription(
|
||||
prepared,
|
||||
accumulated,
|
||||
'translation-only',
|
||||
settings.style,
|
||||
)
|
||||
setUpgradeChangelogTranslation(key, translated)
|
||||
translatedChangelog.value = translated
|
||||
}
|
||||
showTranslation.value = true
|
||||
} catch (error) {
|
||||
translationError.value = translationFailureMessage(error)
|
||||
} finally {
|
||||
translationLoading.value = false
|
||||
await nextTick()
|
||||
positionPopup()
|
||||
}
|
||||
}
|
||||
|
||||
async function openExternalLink(event: MouseEvent) {
|
||||
const target = event.target instanceof Element ? event.target.closest('a') : null
|
||||
if (!target) return
|
||||
event.preventDefault()
|
||||
const url = upgradeExternalChangelogUrl(target.getAttribute('href') ?? '')
|
||||
if (!url) return
|
||||
await openUrl(url)
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (visible.value) positionPopup()
|
||||
}
|
||||
|
||||
function close() {
|
||||
cancelClose()
|
||||
closeTimer = setTimeout(() => {
|
||||
if (!shouldUpgradeChangelogStayOpen(ownership.value)) visible.value = false
|
||||
}, 160)
|
||||
}
|
||||
|
||||
function forceClose() {
|
||||
cancelClose()
|
||||
ownership.value = {
|
||||
triggerHovered: false,
|
||||
triggerFocused: false,
|
||||
popupHovered: false,
|
||||
popupFocused: false,
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (trigger.value?.contains(target) || popup.value?.contains(target)) return
|
||||
forceClose()
|
||||
}
|
||||
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') forceClose()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleViewportChange)
|
||||
window.addEventListener('scroll', handleViewportChange, true)
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.addEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (closeTimer) clearTimeout(closeTimer)
|
||||
window.removeEventListener('resize', handleViewportChange)
|
||||
window.removeEventListener('scroll', handleViewportChange, true)
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.markdown-body a) {
|
||||
color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
724
apps/app-frontend/src/pages/instance/upgrade/analysis.test.ts
Normal file
724
apps/app-frontend/src/pages/instance/upgrade/analysis.test.ts
Normal file
@ -0,0 +1,724 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type {
|
||||
InstanceUpgradePlan,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
actionableWarningContentIds,
|
||||
AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
automaticFabricLoaderTargetAvailable,
|
||||
availablePredefinedStrategies,
|
||||
captureInitialUpgradeBlockingIssues,
|
||||
commitUpgradePlanSelection,
|
||||
compatibilitySummary,
|
||||
confirmDependencyReleaseSlots,
|
||||
confirmSelectionReleaseSlots,
|
||||
confirmSolutionGroups,
|
||||
confirmTargetLoaderLabel,
|
||||
confirmUpgradeOptions,
|
||||
contentIdentityKeys,
|
||||
customConstraintsEqual,
|
||||
editableUpgradeRoots,
|
||||
fabricLoaderVersionForTarget,
|
||||
fabricUpgradeLoaderVersions,
|
||||
groupUpgradeIssues,
|
||||
inferShaderRuntime,
|
||||
isSharedUpgradeInstance,
|
||||
newerStableGameVersions,
|
||||
preserveFabricLoaderSelection,
|
||||
resolveConfirmDependencyReleases,
|
||||
resolveUpgradePlanSelection,
|
||||
sanitizeMinecraftDisplayTitle,
|
||||
setFixedConstraint,
|
||||
shouldReuseUpgradePlan,
|
||||
solutionSummary,
|
||||
upgradeContentDisplayMetadata,
|
||||
upgradeResolutionPresentation,
|
||||
upgradeTargetsEqual,
|
||||
} from './analysis.ts'
|
||||
|
||||
function issue(code: string, contentId: string | null, projectId: string | null, message = code) {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
contentId,
|
||||
provider: projectId ? 'modrinth' : null,
|
||||
projectId,
|
||||
conflictingProjectId: null,
|
||||
dependencyRequirements: [],
|
||||
}
|
||||
}
|
||||
|
||||
function planItem(contentId: string, projectId: string | null = contentId) {
|
||||
return {
|
||||
contentId,
|
||||
relativePath: `mods/${contentId}.jar`,
|
||||
projectType: 'mod',
|
||||
provider: projectId ? 'modrinth' : null,
|
||||
projectId,
|
||||
currentReleaseId: 'old',
|
||||
currentEnabled: true,
|
||||
autoDependency: false,
|
||||
status: 'already_compatible',
|
||||
resolution: {
|
||||
contentId,
|
||||
action: 'upgrade',
|
||||
allowPrerelease: false,
|
||||
confirmedPrereleaseDependencies: [],
|
||||
},
|
||||
candidateReleaseIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
test('newer stable versions follow metadata order without numeric parsing', () => {
|
||||
const result = newerStableGameVersions(
|
||||
[
|
||||
{ version: '26.1.2', version_type: 'release', date: '', major: false },
|
||||
{ version: '26.1-beta', version_type: 'snapshot', date: '', major: false },
|
||||
{ version: '26.1', version_type: 'release', date: '', major: true },
|
||||
{ version: '1.21.8', version_type: 'release', date: '', major: false },
|
||||
{ version: '1.21.7', version_type: 'release', date: '', major: false },
|
||||
],
|
||||
'1.21.8',
|
||||
)
|
||||
|
||||
assert.deepEqual(result, { currentFound: true, versions: ['26.1.2', '26.1'] })
|
||||
})
|
||||
|
||||
test('upgrade target equality uses semantic environment fields', () => {
|
||||
const target = {
|
||||
gameVersion: '26.2',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: '0.18.4',
|
||||
shaderRuntime: 'iris',
|
||||
} as const
|
||||
assert.equal(upgradeTargetsEqual(target, { ...target }), true)
|
||||
assert.equal(upgradeTargetsEqual(target, { ...target, gameVersion: '26.1' }), false)
|
||||
assert.equal(upgradeTargetsEqual(target, { ...target, modLoaderVersion: '1' }), false)
|
||||
})
|
||||
|
||||
test('matching instance and semantic target reuse existing plan without replacing state', () => {
|
||||
const target = {
|
||||
gameVersion: '26.2',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: null,
|
||||
shaderRuntime: 'iris',
|
||||
} as const
|
||||
const plan = {
|
||||
id: 'plan-one',
|
||||
instanceId: 'instance-one',
|
||||
targetEnvironment: target,
|
||||
items: [{ resolution: { action: 'keep' } }],
|
||||
customConstraints: [{ contentId: 'root', versionId: 'fixed' }],
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.equal(shouldReuseUpgradePlan('instance-one', plan, { ...target }), true)
|
||||
assert.equal(plan.id, 'plan-one')
|
||||
assert.equal(plan.items[0].resolution.action, 'keep')
|
||||
assert.equal(plan.customConstraints[0].versionId, 'fixed')
|
||||
assert.equal(
|
||||
shouldReuseUpgradePlan('instance-one', plan, { ...target, gameVersion: '26.1' }),
|
||||
false,
|
||||
)
|
||||
assert.equal(shouldReuseUpgradePlan('instance-two', plan, target), false)
|
||||
assert.equal(shouldReuseUpgradePlan('instance-one', null, target), false)
|
||||
})
|
||||
|
||||
test('plan selection skips planner for matching target and calls it once for a confirmed change', async () => {
|
||||
const target: InstanceUpgradeTargetEnvironment = {
|
||||
gameVersion: '26.2',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: '0.18.4',
|
||||
shaderRuntime: 'iris',
|
||||
}
|
||||
const existing = {
|
||||
id: 'plan-one',
|
||||
instanceId: 'instance-one',
|
||||
targetEnvironment: target,
|
||||
} as InstanceUpgradePlan
|
||||
let calls = 0
|
||||
const planner = async (_instanceId: string, nextTarget: InstanceUpgradeTargetEnvironment) => {
|
||||
calls += 1
|
||||
return {
|
||||
...existing,
|
||||
id: 'plan-two',
|
||||
targetEnvironment: nextTarget,
|
||||
} as InstanceUpgradePlan
|
||||
}
|
||||
|
||||
const reused = await resolveUpgradePlanSelection('instance-one', existing, { ...target }, planner)
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(reused.plan, existing)
|
||||
assert.equal(reused.reused, true)
|
||||
|
||||
const replanned = await resolveUpgradePlanSelection(
|
||||
'instance-one',
|
||||
existing,
|
||||
{ ...target, modLoaderVersion: '0.18.5' },
|
||||
planner,
|
||||
)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(replanned.plan.id, 'plan-two')
|
||||
assert.equal(replanned.reused, false)
|
||||
})
|
||||
|
||||
test('failed replan preserves existing plan and authoritative target', async () => {
|
||||
const oldTarget = {
|
||||
gameVersion: '26.1',
|
||||
modLoader: 'fabric',
|
||||
modLoaderVersion: '0.18.4',
|
||||
shaderRuntime: 'iris',
|
||||
} as const
|
||||
const attemptedTarget = { ...oldTarget, gameVersion: '26.2', modLoaderVersion: '0.18.5' }
|
||||
const oldPlan = {
|
||||
id: 'old-plan',
|
||||
instanceId: 'instance-one',
|
||||
targetEnvironment: oldTarget,
|
||||
} as InstanceUpgradePlan
|
||||
let authoritativePlan = oldPlan
|
||||
let authoritativeTarget = oldTarget
|
||||
|
||||
await assert.rejects(
|
||||
commitUpgradePlanSelection(
|
||||
'instance-one',
|
||||
oldPlan,
|
||||
attemptedTarget,
|
||||
async () => {
|
||||
throw new Error('planning failed')
|
||||
},
|
||||
(plan) => (authoritativePlan = plan),
|
||||
(target) => (authoritativeTarget = target as typeof oldTarget),
|
||||
),
|
||||
/planning failed/,
|
||||
)
|
||||
assert.equal(authoritativePlan, oldPlan)
|
||||
assert.equal(authoritativeTarget, oldTarget)
|
||||
})
|
||||
|
||||
test('Fabric loader choices exclude downgrades using numeric semantic comparison', () => {
|
||||
assert.deepEqual(
|
||||
fabricUpgradeLoaderVersions('0.18.4', ['0.18.6', '0.18.5', '0.18.4', '0.18.3']),
|
||||
['0.18.6', '0.18.5', '0.18.4'],
|
||||
)
|
||||
assert.deepEqual(fabricUpgradeLoaderVersions('0.18.9', ['0.18.10', '0.18.9']), [
|
||||
'0.18.10',
|
||||
'0.18.9',
|
||||
])
|
||||
assert.deepEqual(fabricUpgradeLoaderVersions('custom', ['0.19.0']), [])
|
||||
})
|
||||
|
||||
test('Fabric loader pending selection preserves valid exact values and maps target values', () => {
|
||||
assert.equal(preserveFabricLoaderSelection('0.18.5', ['0.18.5']), '0.18.5')
|
||||
assert.equal(preserveFabricLoaderSelection('0.18.5', ['0.18.6']), AUTOMATIC_FABRIC_LOADER_VERSION)
|
||||
assert.equal(
|
||||
preserveFabricLoaderSelection(AUTOMATIC_FABRIC_LOADER_VERSION, []),
|
||||
AUTOMATIC_FABRIC_LOADER_VERSION,
|
||||
)
|
||||
assert.equal(fabricLoaderVersionForTarget(AUTOMATIC_FABRIC_LOADER_VERSION), null)
|
||||
assert.equal(fabricLoaderVersionForTarget('0.18.5'), '0.18.5')
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(true, true, []), false)
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(true, true, ['0.18.5']), true)
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(false, true, []), true)
|
||||
assert.equal(automaticFabricLoaderTargetAvailable(true, false, []), true)
|
||||
})
|
||||
|
||||
test('shared upgrade detection uses established link or external target metadata', () => {
|
||||
assert.equal(isSharedUpgradeInstance({ link: { type: 'shared_instance' } } as never), true)
|
||||
assert.equal(isSharedUpgradeInstance({ symlink_target: 'D:/Minecraft' } as never), true)
|
||||
assert.equal(isSharedUpgradeInstance({ link: null, symlink_target: null } as never), false)
|
||||
})
|
||||
|
||||
test('unknown current version exposes stable releases conservatively', () => {
|
||||
const result = newerStableGameVersions(
|
||||
[
|
||||
{ version: '26.1', version_type: 'release', date: '', major: true },
|
||||
{ version: '26.1-beta', version_type: 'snapshot', date: '', major: false },
|
||||
],
|
||||
'custom',
|
||||
)
|
||||
|
||||
assert.deepEqual(result, { currentFound: false, versions: ['26.1'] })
|
||||
})
|
||||
|
||||
test('compatibility summary uses selected solution and changed dependencies', () => {
|
||||
const plan = {
|
||||
blockingIssues: [{ code: 'dependency_conflict' }, { code: 'prerelease_only' }],
|
||||
selectedSolution: {
|
||||
selections: [
|
||||
{ action: 'upgrade', currentReleaseId: 'old', targetReleaseId: 'new' },
|
||||
{ action: 'keep', currentReleaseId: 'same', targetReleaseId: 'same' },
|
||||
{ action: 'disable', currentReleaseId: 'off', targetReleaseId: null },
|
||||
],
|
||||
dependencyChanges: [{ kind: 'add' }, { kind: 'keep' }, { kind: 'remove' }],
|
||||
},
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.deepEqual(compatibilitySummary(plan), {
|
||||
updates: 1,
|
||||
keptOrCompatible: 1,
|
||||
disabled: 1,
|
||||
dependencyChanges: 2,
|
||||
needsAttention: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('shader runtime inference uses exact loader component and provider identity', () => {
|
||||
const instance = {
|
||||
loader: 'fabric',
|
||||
loader_components: [],
|
||||
} as never
|
||||
const snapshot = {
|
||||
items: [
|
||||
{
|
||||
projectType: 'mod',
|
||||
provider: 'modrinth',
|
||||
providerProjectId: 'YL57xq9U',
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
} as never
|
||||
|
||||
assert.equal(inferShaderRuntime(instance, snapshot), 'iris')
|
||||
assert.equal(
|
||||
inferShaderRuntime({ ...instance, loader_components: [{ kind: 'optifine' }] }, undefined),
|
||||
'opti_fine',
|
||||
)
|
||||
assert.equal(inferShaderRuntime(instance, undefined), 'unknown')
|
||||
})
|
||||
|
||||
test('solution summary separates root and dependency changes', () => {
|
||||
const summary = solutionSummary({
|
||||
kind: 'newest',
|
||||
selections: [
|
||||
{
|
||||
contentId: 'a',
|
||||
provider: 'modrinth',
|
||||
projectId: 'a',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: '2',
|
||||
action: 'upgrade',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
contentId: 'b',
|
||||
provider: 'modrinth',
|
||||
projectId: 'b',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: '1',
|
||||
action: 'keep',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
contentId: 'c',
|
||||
provider: 'modrinth',
|
||||
projectId: 'c',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: null,
|
||||
action: 'disable',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
dependencyChanges: [
|
||||
{
|
||||
existingContentId: null,
|
||||
provider: 'modrinth',
|
||||
projectId: 'd',
|
||||
currentReleaseId: null,
|
||||
targetReleaseId: '1',
|
||||
kind: 'add',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
existingContentId: 'e',
|
||||
provider: 'modrinth',
|
||||
projectId: 'e',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: '2',
|
||||
kind: 'upgrade',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
existingContentId: 'f',
|
||||
provider: 'modrinth',
|
||||
projectId: 'f',
|
||||
currentReleaseId: '1',
|
||||
targetReleaseId: null,
|
||||
kind: 'remove',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
})
|
||||
assert.deepEqual(summary, {
|
||||
upgraded: 1,
|
||||
kept: 1,
|
||||
disabled: 1,
|
||||
dependencyAdditions: 1,
|
||||
dependencyUpdates: 1,
|
||||
dependencyRemovals: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('confirm detail groups follow authoritative selection actions', () => {
|
||||
const solution = {
|
||||
kind: 'custom',
|
||||
selections: [
|
||||
{ contentId: 'update', action: 'upgrade', currentReleaseId: '1', targetReleaseId: '2' },
|
||||
{ contentId: 'same', action: 'upgrade', currentReleaseId: '1', targetReleaseId: '1' },
|
||||
{ contentId: 'keep', action: 'keep', currentReleaseId: '1', targetReleaseId: '1' },
|
||||
{ contentId: 'disable', action: 'disable', currentReleaseId: '1', targetReleaseId: null },
|
||||
],
|
||||
dependencyChanges: [{ kind: 'add' }, { kind: 'upgrade' }, { kind: 'keep' }, { kind: 'remove' }],
|
||||
warnings: [],
|
||||
} as never
|
||||
const groups = confirmSolutionGroups(solution)
|
||||
|
||||
assert.deepEqual(
|
||||
groups.updated.map((item) => item.contentId),
|
||||
['update'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.kept.map((item) => item.contentId),
|
||||
['same', 'keep'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.disabled.map((item) => item.contentId),
|
||||
['disable'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.dependencyChanges.map((item) => item.kind),
|
||||
['add', 'upgrade', 'remove'],
|
||||
)
|
||||
})
|
||||
|
||||
test('confirm upgrade options require shared mode and suppress redundant copy backup', () => {
|
||||
assert.deepEqual(confirmUpgradeOptions(false, null, true), {
|
||||
effectiveMode: 'direct',
|
||||
createFullBackup: true,
|
||||
canStart: true,
|
||||
})
|
||||
assert.deepEqual(confirmUpgradeOptions(true, null, true), {
|
||||
effectiveMode: null,
|
||||
createFullBackup: true,
|
||||
canStart: false,
|
||||
})
|
||||
assert.deepEqual(confirmUpgradeOptions(true, 'direct', false), {
|
||||
effectiveMode: 'direct',
|
||||
createFullBackup: false,
|
||||
canStart: true,
|
||||
})
|
||||
assert.deepEqual(confirmUpgradeOptions(true, 'copy_and_upgrade', true), {
|
||||
effectiveMode: 'copy_and_upgrade',
|
||||
createFullBackup: false,
|
||||
canStart: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('confirm release slots keep current and target changelogs independent', () => {
|
||||
assert.deepEqual(
|
||||
confirmSelectionReleaseSlots({
|
||||
action: 'upgrade',
|
||||
currentReleaseId: 'current',
|
||||
targetReleaseId: 'target',
|
||||
} as never),
|
||||
{ currentReleaseId: 'current', targetReleaseId: 'target' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
confirmSelectionReleaseSlots({
|
||||
action: 'keep',
|
||||
currentReleaseId: 'current',
|
||||
targetReleaseId: 'current',
|
||||
} as never),
|
||||
{ currentReleaseId: 'current', targetReleaseId: null },
|
||||
)
|
||||
assert.deepEqual(
|
||||
confirmDependencyReleaseSlots({
|
||||
currentReleaseId: 'current',
|
||||
targetReleaseId: 'target',
|
||||
} as never),
|
||||
{ currentReleaseId: 'current', targetReleaseId: 'target' },
|
||||
)
|
||||
})
|
||||
|
||||
test('dependency detail resolves deterministic current and target release slots', () => {
|
||||
const cases = [
|
||||
{
|
||||
change: { kind: 'upgrade', currentReleaseId: 'old', targetReleaseId: 'new' },
|
||||
expected: {
|
||||
currentReleaseId: 'old',
|
||||
targetReleaseId: 'new',
|
||||
current: 'old-label',
|
||||
target: 'new-label',
|
||||
},
|
||||
},
|
||||
{
|
||||
change: { kind: 'add', currentReleaseId: null, targetReleaseId: 'new' },
|
||||
expected: {
|
||||
currentReleaseId: null,
|
||||
targetReleaseId: 'new',
|
||||
current: null,
|
||||
target: 'new-label',
|
||||
},
|
||||
},
|
||||
{
|
||||
change: { kind: 'remove', currentReleaseId: 'old', targetReleaseId: null },
|
||||
expected: {
|
||||
currentReleaseId: 'old',
|
||||
targetReleaseId: null,
|
||||
current: 'old-label',
|
||||
target: null,
|
||||
},
|
||||
},
|
||||
]
|
||||
for (const { change, expected } of cases) {
|
||||
assert.deepEqual(
|
||||
resolveConfirmDependencyReleases(change as never, (releaseId) =>
|
||||
releaseId ? `${releaseId}-label` : null,
|
||||
),
|
||||
expected,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('confirm target loader label shows explicit version or honest automatic policy', () => {
|
||||
assert.equal(confirmTargetLoaderLabel('Fabric', 'fabric', '0.18.4', 'automatic'), 'Fabric 0.18.4')
|
||||
assert.equal(
|
||||
confirmTargetLoaderLabel('Fabric', 'fabric', null, 'automatic'),
|
||||
'Fabric (automatic)',
|
||||
)
|
||||
assert.equal(confirmTargetLoaderLabel('Vanilla', 'vanilla', null, 'automatic'), 'Vanilla')
|
||||
})
|
||||
|
||||
test('fixed constraints replace and remove by physical content without duplicates', () => {
|
||||
const first = {
|
||||
contentId: 'a',
|
||||
provider: 'modrinth' as const,
|
||||
projectId: 'project',
|
||||
versionId: 'one',
|
||||
}
|
||||
const replaced = setFixedConstraint([first], { ...first, versionId: 'two' }, 'a')
|
||||
assert.deepEqual(replaced, [{ ...first, versionId: 'two' }])
|
||||
assert.deepEqual(setFixedConstraint(replaced, null, 'a'), [])
|
||||
assert.equal(customConstraintsEqual(replaced, [{ ...first, versionId: 'two' }]), true)
|
||||
})
|
||||
|
||||
test('editable roots exclude automatic dependencies', () => {
|
||||
const root = {
|
||||
contentId: 'root',
|
||||
autoDependency: false,
|
||||
provider: 'modrinth',
|
||||
projectId: 'root',
|
||||
candidateReleaseIds: ['one'],
|
||||
}
|
||||
const dependency = { ...root, contentId: 'dependency', autoDependency: true }
|
||||
const plan = { items: [root, dependency], customConstraints: [] } as InstanceUpgradePlan
|
||||
assert.deepEqual(
|
||||
editableUpgradeRoots(plan).map((item) => item.contentId),
|
||||
['root'],
|
||||
)
|
||||
})
|
||||
|
||||
test('unavailable minimal solution is not selectable', () => {
|
||||
const newestSolution = { kind: 'newest', selections: [], dependencyChanges: [], warnings: [] }
|
||||
assert.deepEqual(
|
||||
availablePredefinedStrategies({
|
||||
newestSolution,
|
||||
minimalChangeSolution: null,
|
||||
} as InstanceUpgradePlan),
|
||||
['newest'],
|
||||
)
|
||||
})
|
||||
|
||||
test('issue grouping gives blocking precedence and includes every content once', () => {
|
||||
const plan = {
|
||||
items: [planItem('blocked'), planItem('warned'), planItem('clear')],
|
||||
blockingIssues: [issue('dependency_conflict', 'blocked', 'blocked')],
|
||||
warnings: [
|
||||
issue('keep_incompatible', 'blocked', 'blocked'),
|
||||
issue('keep_incompatible', 'warned', 'warned'),
|
||||
],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(plan)
|
||||
|
||||
assert.deepEqual(
|
||||
groups.blocking.map((group) => group.item.contentId),
|
||||
['blocked'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.warnings.map((group) => group.item.contentId),
|
||||
['warned'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.noIssues.map((group) => group.item.contentId),
|
||||
['clear'],
|
||||
)
|
||||
assert.equal(
|
||||
new Set(
|
||||
[...groups.blocking, ...groups.warnings, ...groups.noIssues].map(
|
||||
(group) => group.item.contentId,
|
||||
),
|
||||
).size,
|
||||
3,
|
||||
)
|
||||
})
|
||||
|
||||
test('initial blockers stay in blocking presentation without duplication after resolution', () => {
|
||||
const initialPlan = {
|
||||
items: [planItem('voxy'), planItem('clear')],
|
||||
blockingIssues: [issue('prerelease_only', 'voxy', 'voxy')],
|
||||
warnings: [],
|
||||
} as InstanceUpgradePlan
|
||||
const initial = captureInitialUpgradeBlockingIssues(initialPlan)
|
||||
const resolvedPlan = {
|
||||
...initialPlan,
|
||||
blockingIssues: [],
|
||||
warnings: [issue('keep_incompatible', 'voxy', 'voxy')],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(resolvedPlan, initial)
|
||||
|
||||
assert.deepEqual(
|
||||
groups.blocking.map((group) => group.item.contentId),
|
||||
['voxy'],
|
||||
)
|
||||
assert.equal(groups.blocking[0].currentlyBlocking, false)
|
||||
assert.equal(groups.blocking[0].warnings.length, 2)
|
||||
assert.equal(groups.warnings.length, 0)
|
||||
assert.deepEqual(
|
||||
groups.noIssues.map((group) => group.item.contentId),
|
||||
['clear'],
|
||||
)
|
||||
})
|
||||
|
||||
test('resolution presentation follows authoritative plan resolution rules', () => {
|
||||
const resolution = planItem('item').resolution
|
||||
assert.deepEqual(upgradeResolutionPresentation('two-option', resolution), {
|
||||
selectedAction: null,
|
||||
showUndo: false,
|
||||
})
|
||||
assert.deepEqual(upgradeResolutionPresentation('two-option', { ...resolution, action: 'keep' }), {
|
||||
selectedAction: 'keep',
|
||||
showUndo: false,
|
||||
})
|
||||
assert.deepEqual(
|
||||
upgradeResolutionPresentation('two-option', { ...resolution, action: 'disable' }),
|
||||
{ selectedAction: 'disable', showUndo: false },
|
||||
)
|
||||
assert.deepEqual(upgradeResolutionPresentation('single-prerelease', resolution), {
|
||||
selectedAction: null,
|
||||
showUndo: false,
|
||||
})
|
||||
assert.deepEqual(
|
||||
upgradeResolutionPresentation('single-prerelease', {
|
||||
...resolution,
|
||||
allowPrerelease: true,
|
||||
}),
|
||||
{ selectedAction: 'upgrade', showUndo: true },
|
||||
)
|
||||
})
|
||||
|
||||
test('root and content forms of one issue coalesce on exact project identity', () => {
|
||||
const plan = {
|
||||
items: [planItem('content', 'project')],
|
||||
blockingIssues: [
|
||||
issue('no_compatible_release', null, 'project', 'root form'),
|
||||
issue('no_compatible_release', 'content', 'project', 'content form'),
|
||||
],
|
||||
warnings: [],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(plan)
|
||||
|
||||
assert.equal(groups.blocking[0].blockingIssues.length, 1)
|
||||
assert.equal(groups.blocking[0].blockingIssues[0].message, 'content form')
|
||||
})
|
||||
|
||||
test('unmapped and ambiguous project issues remain global', () => {
|
||||
const duplicate = planItem('duplicate-b', 'duplicate')
|
||||
const plan = {
|
||||
items: [planItem('duplicate-a', 'duplicate'), duplicate],
|
||||
blockingIssues: [issue('dependency_conflict', null, 'missing')],
|
||||
warnings: [issue('keep_incompatible', null, 'duplicate')],
|
||||
} as InstanceUpgradePlan
|
||||
const groups = groupUpgradeIssues(plan)
|
||||
|
||||
assert.equal(groups.globalBlockingIssues.length, 1)
|
||||
assert.equal(groups.globalWarnings.length, 1)
|
||||
assert.equal(groups.noIssues.length, 2)
|
||||
})
|
||||
|
||||
test('actionable warning filtering excludes global and informational conflicts', () => {
|
||||
const plan = {
|
||||
items: [planItem('actionable'), planItem('conflict')],
|
||||
blockingIssues: [],
|
||||
warnings: [
|
||||
issue('keep_incompatible', 'actionable', 'actionable'),
|
||||
issue('dependency_conflict', 'conflict', 'conflict'),
|
||||
issue('keep_incompatible', null, null),
|
||||
],
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.deepEqual(actionableWarningContentIds(groupUpgradeIssues(plan)), ['actionable'])
|
||||
})
|
||||
|
||||
test('actionable warning count uses unique content rows', () => {
|
||||
const plan = {
|
||||
items: [planItem('one'), planItem('two')],
|
||||
blockingIssues: [],
|
||||
warnings: [
|
||||
issue('keep_incompatible', 'one', 'one'),
|
||||
issue('shader_runtime_unknown', 'one', 'one'),
|
||||
issue('unidentified', 'two', 'two'),
|
||||
],
|
||||
} as InstanceUpgradePlan
|
||||
|
||||
assert.equal(actionableWarningContentIds(groupUpgradeIssues(plan)).length, 2)
|
||||
})
|
||||
|
||||
test('content display metadata prefers normalized content then snapshot then plan fallback', () => {
|
||||
const item = planItem('entry', 'plan-project') as never
|
||||
const snapshot = {
|
||||
expectedRelativePath: 'resourcepacks/file.zip',
|
||||
content: {
|
||||
project: { title: 'Snapshot title', icon_url: 'snapshot.png' },
|
||||
version: { version_number: 'snapshot-version' },
|
||||
},
|
||||
} as never
|
||||
const content = {
|
||||
project: { title: 'Resolved title', icon_url: 'resolved.png' },
|
||||
version: { version_number: 'resolved-version' },
|
||||
} as never
|
||||
|
||||
assert.deepEqual(upgradeContentDisplayMetadata(item, content, snapshot), {
|
||||
title: 'Resolved title',
|
||||
iconUrl: 'resolved.png',
|
||||
currentVersion: 'resolved-version',
|
||||
})
|
||||
assert.deepEqual(upgradeContentDisplayMetadata(item, undefined, snapshot), {
|
||||
title: 'Snapshot title',
|
||||
iconUrl: 'snapshot.png',
|
||||
currentVersion: 'snapshot-version',
|
||||
})
|
||||
assert.equal(upgradeContentDisplayMetadata(item).title, 'entry.jar')
|
||||
})
|
||||
|
||||
test('local content identity joins by normalized path when entry ids are absent', () => {
|
||||
assert.deepEqual(contentIdentityKeys({ relativePath: 'resourcepacks\\pack.zip' }), [
|
||||
'resourcepacks/pack.zip',
|
||||
])
|
||||
assert.deepEqual(
|
||||
contentIdentityKeys({ instanceEntryId: 'entry', relativePath: 'resourcepacks/pack.zip' }),
|
||||
['entry', 'resourcepacks/pack.zip'],
|
||||
)
|
||||
})
|
||||
|
||||
test('Minecraft formatting codes are removed from display title only', () => {
|
||||
const item = planItem('identity')
|
||||
item.relativePath = 'resourcepacks/§9§lExample §rPack.zip'
|
||||
const originalPath = item.relativePath
|
||||
assert.equal(sanitizeMinecraftDisplayTitle('§9§lExample §rPack'), 'Example Pack')
|
||||
assert.equal(upgradeContentDisplayMetadata(item).title, 'Example Pack.zip')
|
||||
assert.equal(item.relativePath, originalPath)
|
||||
assert.equal(item.contentId, 'identity')
|
||||
})
|
||||
611
apps/app-frontend/src/pages/instance/upgrade/analysis.ts
Normal file
611
apps/app-frontend/src/pages/instance/upgrade/analysis.ts
Normal file
@ -0,0 +1,611 @@
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
|
||||
import type { InstanceContentSnapshot, InstanceContentSnapshotItem } from '@/helpers/instance'
|
||||
import type {
|
||||
InstanceUpgradeDependencyChange,
|
||||
InstanceUpgradeFixedConstraint,
|
||||
InstanceUpgradeIssue,
|
||||
InstanceUpgradePlan,
|
||||
InstanceUpgradePlanItem,
|
||||
InstanceUpgradeSolution,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
ShaderRuntime,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { compareSemanticVersions } from '../../../helpers/version-compatibility.ts'
|
||||
|
||||
export const AUTOMATIC_FABRIC_LOADER_VERSION = '__automatic__'
|
||||
|
||||
export interface UpgradeVersionTargets {
|
||||
currentFound: boolean
|
||||
versions: string[]
|
||||
}
|
||||
|
||||
export interface CompatibilitySummary {
|
||||
updates: number
|
||||
keptOrCompatible: number
|
||||
disabled: number
|
||||
dependencyChanges: number
|
||||
needsAttention: number
|
||||
}
|
||||
|
||||
export interface SolutionSummary {
|
||||
upgraded: number
|
||||
kept: number
|
||||
disabled: number
|
||||
dependencyAdditions: number
|
||||
dependencyUpdates: number
|
||||
dependencyRemovals: number
|
||||
}
|
||||
|
||||
export interface ConfirmSolutionGroups {
|
||||
updated: InstanceUpgradeSolution['selections']
|
||||
kept: InstanceUpgradeSolution['selections']
|
||||
disabled: InstanceUpgradeSolution['selections']
|
||||
dependencyChanges: InstanceUpgradeDependencyChange[]
|
||||
}
|
||||
|
||||
export interface ConfirmUpgradeOptions {
|
||||
effectiveMode: 'direct' | 'copy_and_upgrade' | null
|
||||
createFullBackup: boolean
|
||||
canStart: boolean
|
||||
}
|
||||
|
||||
export interface ConfirmReleaseSlots {
|
||||
currentReleaseId: string | null
|
||||
targetReleaseId: string | null
|
||||
}
|
||||
|
||||
export interface ConfirmResolvedReleaseSlots extends ConfirmReleaseSlots {
|
||||
current: string | null
|
||||
target: string | null
|
||||
}
|
||||
|
||||
export interface UpgradeContentIssueGroup {
|
||||
item: InstanceUpgradePlanItem
|
||||
blockingIssues: InstanceUpgradeIssue[]
|
||||
warnings: InstanceUpgradeIssue[]
|
||||
startedBlocking: boolean
|
||||
currentlyBlocking: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeIssueGroups {
|
||||
blocking: UpgradeContentIssueGroup[]
|
||||
warnings: UpgradeContentIssueGroup[]
|
||||
noIssues: UpgradeContentIssueGroup[]
|
||||
globalBlockingIssues: InstanceUpgradeIssue[]
|
||||
globalWarnings: InstanceUpgradeIssue[]
|
||||
}
|
||||
|
||||
export interface UpgradeContentDisplayMetadata {
|
||||
title: string
|
||||
iconUrl: string | null
|
||||
currentVersion: string | null
|
||||
}
|
||||
|
||||
export type InitialUpgradeBlockingIssues = Record<string, InstanceUpgradeIssue[]>
|
||||
|
||||
export interface UpgradeResolutionPresentation {
|
||||
selectedAction: 'upgrade' | 'keep' | 'disable' | null
|
||||
showUndo: boolean
|
||||
}
|
||||
|
||||
export function normalizeUpgradePath(path: string): string {
|
||||
return path.replaceAll('\\', '/').replace(/\/+/g, '/').replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
export function upgradeTargetsEqual(
|
||||
left: InstanceUpgradeTargetEnvironment,
|
||||
right: InstanceUpgradeTargetEnvironment,
|
||||
): boolean {
|
||||
return (
|
||||
left.gameVersion === right.gameVersion &&
|
||||
left.modLoader === right.modLoader &&
|
||||
left.modLoaderVersion === right.modLoaderVersion &&
|
||||
left.shaderRuntime === right.shaderRuntime
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldReuseUpgradePlan(
|
||||
instanceId: string,
|
||||
plan: InstanceUpgradePlan | null,
|
||||
target: InstanceUpgradeTargetEnvironment | null,
|
||||
): boolean {
|
||||
return (
|
||||
plan !== null &&
|
||||
target !== null &&
|
||||
plan.instanceId === instanceId &&
|
||||
upgradeTargetsEqual(plan.targetEnvironment, target)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveUpgradePlanSelection(
|
||||
instanceId: string,
|
||||
existingPlan: InstanceUpgradePlan | null,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
planUpgrade: (
|
||||
instanceId: string,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
) => Promise<InstanceUpgradePlan>,
|
||||
): Promise<{ plan: InstanceUpgradePlan; reused: boolean }> {
|
||||
if (shouldReuseUpgradePlan(instanceId, existingPlan, target)) {
|
||||
return { plan: existingPlan!, reused: true }
|
||||
}
|
||||
return { plan: await planUpgrade(instanceId, target), reused: false }
|
||||
}
|
||||
|
||||
export async function commitUpgradePlanSelection(
|
||||
instanceId: string,
|
||||
existingPlan: InstanceUpgradePlan | null,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
planUpgrade: (
|
||||
instanceId: string,
|
||||
target: InstanceUpgradeTargetEnvironment,
|
||||
) => Promise<InstanceUpgradePlan>,
|
||||
commitPlan: (plan: InstanceUpgradePlan) => void,
|
||||
commitTarget: (target: InstanceUpgradeTargetEnvironment) => void,
|
||||
): Promise<{ plan: InstanceUpgradePlan; reused: boolean }> {
|
||||
const result = await resolveUpgradePlanSelection(instanceId, existingPlan, target, planUpgrade)
|
||||
if (!result.reused) commitPlan(result.plan)
|
||||
commitTarget(result.plan.targetEnvironment)
|
||||
return result
|
||||
}
|
||||
|
||||
export function fabricUpgradeLoaderVersions(
|
||||
currentVersion: string | null | undefined,
|
||||
availableVersions: readonly string[],
|
||||
): string[] {
|
||||
if (!currentVersion || compareSemanticVersions(currentVersion, currentVersion) === null) return []
|
||||
return availableVersions.filter((version) => {
|
||||
const comparison = compareSemanticVersions(version, currentVersion)
|
||||
return comparison !== null && comparison >= 0
|
||||
})
|
||||
}
|
||||
|
||||
export function preserveFabricLoaderSelection(
|
||||
selectedVersion: string,
|
||||
availableVersions: readonly string[],
|
||||
): string {
|
||||
return selectedVersion === AUTOMATIC_FABRIC_LOADER_VERSION ||
|
||||
availableVersions.includes(selectedVersion)
|
||||
? selectedVersion
|
||||
: AUTOMATIC_FABRIC_LOADER_VERSION
|
||||
}
|
||||
|
||||
export function fabricLoaderVersionForTarget(selectedVersion: string): string | null {
|
||||
return selectedVersion === AUTOMATIC_FABRIC_LOADER_VERSION ? null : selectedVersion
|
||||
}
|
||||
|
||||
export function automaticFabricLoaderTargetAvailable(
|
||||
metadataLoaded: boolean,
|
||||
currentVersionComparable: boolean,
|
||||
availableVersions: readonly string[],
|
||||
): boolean {
|
||||
return !metadataLoaded || !currentVersionComparable || availableVersions.length > 0
|
||||
}
|
||||
|
||||
export function isSharedUpgradeInstance(instance: GameInstance): boolean {
|
||||
return instance.link?.type === 'shared_instance' || Boolean(instance.symlink_target)
|
||||
}
|
||||
|
||||
export function contentIdentityKeys(item: {
|
||||
contentId?: string | null
|
||||
relativePath?: string | null
|
||||
instanceEntryId?: string | null
|
||||
instanceMemberId?: string | null
|
||||
instanceFileId?: string | null
|
||||
id?: string | null
|
||||
file_path?: string | null
|
||||
}): string[] {
|
||||
return [
|
||||
item.contentId,
|
||||
item.instanceEntryId,
|
||||
item.instanceMemberId,
|
||||
item.instanceFileId,
|
||||
item.id,
|
||||
item.relativePath ? normalizeUpgradePath(item.relativePath) : null,
|
||||
item.file_path ? normalizeUpgradePath(item.file_path) : null,
|
||||
].filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
const ACTIONABLE_WARNING_CODES = new Set<InstanceUpgradeIssue['code']>([
|
||||
'unidentified',
|
||||
'unsupported_content_type',
|
||||
'prerelease_only',
|
||||
'no_compatible_release',
|
||||
'no_compatible_shader_runtime',
|
||||
'shader_runtime_missing',
|
||||
'shader_runtime_unknown',
|
||||
'keep_incompatible',
|
||||
])
|
||||
|
||||
function issueIdentity(issue: InstanceUpgradeIssue): string {
|
||||
const requirements = issue.dependencyRequirements
|
||||
.map((requirement) =>
|
||||
[
|
||||
requirement.rootContentId,
|
||||
requirement.parentProvider,
|
||||
requirement.parentProjectId,
|
||||
requirement.parentReleaseId,
|
||||
requirement.dependencyProvider,
|
||||
requirement.dependencyProjectId,
|
||||
requirement.requiredReleaseId ?? '',
|
||||
requirement.candidateReleaseId ?? '',
|
||||
].join(':'),
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
return [
|
||||
issue.code,
|
||||
issue.provider ?? '',
|
||||
issue.projectId ?? '',
|
||||
issue.conflictingProjectId ?? '',
|
||||
requirements,
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function issueContentId(
|
||||
issue: InstanceUpgradeIssue,
|
||||
itemsById: Map<string, InstanceUpgradePlanItem>,
|
||||
itemsByProject: Map<string, InstanceUpgradePlanItem | null>,
|
||||
): string | null {
|
||||
if (issue.contentId && itemsById.has(issue.contentId)) return issue.contentId
|
||||
if (!issue.projectId) return null
|
||||
const providerProject = `${issue.provider ?? ''}:${issue.projectId}`
|
||||
return itemsByProject.get(providerProject)?.contentId ?? null
|
||||
}
|
||||
|
||||
export function captureInitialUpgradeBlockingIssues(
|
||||
plan: InstanceUpgradePlan,
|
||||
): InitialUpgradeBlockingIssues {
|
||||
return Object.fromEntries(
|
||||
groupUpgradeIssues(plan).blocking.map((group) => [group.item.contentId, group.blockingIssues]),
|
||||
)
|
||||
}
|
||||
|
||||
export function groupUpgradeIssues(
|
||||
plan: InstanceUpgradePlan,
|
||||
initialBlockingIssues: InitialUpgradeBlockingIssues = {},
|
||||
): UpgradeIssueGroups {
|
||||
const itemsById = new Map(plan.items.map((item) => [item.contentId, item]))
|
||||
const itemsByProject = new Map<string, InstanceUpgradePlanItem | null>()
|
||||
for (const item of plan.items) {
|
||||
if (!item.projectId) continue
|
||||
const key = `${item.provider ?? ''}:${item.projectId}`
|
||||
itemsByProject.set(key, itemsByProject.has(key) ? null : item)
|
||||
}
|
||||
|
||||
const blockingByContent = new Map<string, Map<string, InstanceUpgradeIssue>>()
|
||||
const warningByContent = new Map<string, Map<string, InstanceUpgradeIssue>>()
|
||||
const globalBlockingIssues: InstanceUpgradeIssue[] = []
|
||||
const globalWarnings: InstanceUpgradeIssue[] = []
|
||||
|
||||
function collect(
|
||||
issue: InstanceUpgradeIssue,
|
||||
byContent: Map<string, Map<string, InstanceUpgradeIssue>>,
|
||||
global: InstanceUpgradeIssue[],
|
||||
) {
|
||||
const contentId = issueContentId(issue, itemsById, itemsByProject)
|
||||
if (!contentId) {
|
||||
global.push(issue)
|
||||
return
|
||||
}
|
||||
const issues = byContent.get(contentId) ?? new Map<string, InstanceUpgradeIssue>()
|
||||
const key = issueIdentity(issue)
|
||||
const existing = issues.get(key)
|
||||
if (!existing || (existing.contentId === null && issue.contentId !== null))
|
||||
issues.set(key, issue)
|
||||
byContent.set(contentId, issues)
|
||||
}
|
||||
|
||||
for (const issue of plan.blockingIssues) collect(issue, blockingByContent, globalBlockingIssues)
|
||||
for (const issue of plan.warnings) collect(issue, warningByContent, globalWarnings)
|
||||
|
||||
const blocking: UpgradeContentIssueGroup[] = []
|
||||
const warnings: UpgradeContentIssueGroup[] = []
|
||||
const noIssues: UpgradeContentIssueGroup[] = []
|
||||
for (const item of plan.items) {
|
||||
const itemBlocking = [...(blockingByContent.get(item.contentId)?.values() ?? [])]
|
||||
const blockingKeys = new Set(itemBlocking.map(issueIdentity))
|
||||
const itemWarnings = [...(warningByContent.get(item.contentId)?.values() ?? [])].filter(
|
||||
(issue) => !blockingKeys.has(issueIdentity(issue)),
|
||||
)
|
||||
const initialIssues = initialBlockingIssues[item.contentId] ?? []
|
||||
const startedBlocking = initialIssues.length > 0
|
||||
const currentlyBlocking = itemBlocking.length > 0
|
||||
const contextualWarnings = currentlyBlocking
|
||||
? itemWarnings
|
||||
: [
|
||||
...new Map(
|
||||
[...initialIssues, ...itemWarnings].map((issue) => [issueIdentity(issue), issue]),
|
||||
).values(),
|
||||
]
|
||||
const group = {
|
||||
item,
|
||||
blockingIssues: itemBlocking,
|
||||
warnings: contextualWarnings,
|
||||
startedBlocking,
|
||||
currentlyBlocking,
|
||||
}
|
||||
if (currentlyBlocking || startedBlocking) blocking.push(group)
|
||||
else if (itemWarnings.length) warnings.push(group)
|
||||
else noIssues.push(group)
|
||||
}
|
||||
|
||||
return { blocking, warnings, noIssues, globalBlockingIssues, globalWarnings }
|
||||
}
|
||||
|
||||
export function actionableWarningContentIds(groups: UpgradeIssueGroups): string[] {
|
||||
return groups.warnings
|
||||
.filter((group) => group.warnings.some((issue) => ACTIONABLE_WARNING_CODES.has(issue.code)))
|
||||
.map((group) => group.item.contentId)
|
||||
}
|
||||
|
||||
export function upgradeContentDisplayMetadata(
|
||||
item: InstanceUpgradePlanItem,
|
||||
contentItem?: ContentItem,
|
||||
snapshotItem?: InstanceContentSnapshotItem,
|
||||
): UpgradeContentDisplayMetadata {
|
||||
const fallbackPath = snapshotItem?.expectedRelativePath ?? item.relativePath
|
||||
const fallbackName = fallbackPath.split('/').pop() ?? fallbackPath
|
||||
return {
|
||||
title: sanitizeMinecraftDisplayTitle(
|
||||
contentItem?.project.title ?? snapshotItem?.content?.project.title ?? fallbackName,
|
||||
),
|
||||
iconUrl: contentItem?.project.icon_url ?? snapshotItem?.content?.project.icon_url ?? null,
|
||||
currentVersion:
|
||||
contentItem?.version?.version_number ??
|
||||
snapshotItem?.content?.version?.version_number ??
|
||||
item.currentReleaseId,
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeMinecraftDisplayTitle(title: string): string {
|
||||
return title.replace(/§[0-9a-fk-or]/gi, '')
|
||||
}
|
||||
|
||||
export function upgradeResolutionPresentation(
|
||||
kind: 'two-option' | 'single-prerelease',
|
||||
resolution: InstanceUpgradePlanItem['resolution'],
|
||||
): UpgradeResolutionPresentation {
|
||||
if (kind === 'single-prerelease') {
|
||||
return {
|
||||
selectedAction: resolution.allowPrerelease ? 'upgrade' : null,
|
||||
showUndo: resolution.allowPrerelease,
|
||||
}
|
||||
}
|
||||
return {
|
||||
selectedAction:
|
||||
resolution.action === 'keep' || resolution.action === 'disable' ? resolution.action : null,
|
||||
showUndo: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function availablePredefinedStrategies(plan: InstanceUpgradePlan) {
|
||||
return [
|
||||
...(plan.newestSolution ? (['newest'] as const) : []),
|
||||
...(plan.minimalChangeSolution ? (['minimal_change'] as const) : []),
|
||||
]
|
||||
}
|
||||
|
||||
const IRIS_MODRINTH_PROJECT_ID = 'YL57xq9U'
|
||||
|
||||
export function inferShaderRuntime(
|
||||
instance: GameInstance,
|
||||
snapshot: InstanceContentSnapshot | undefined,
|
||||
): ShaderRuntime {
|
||||
if (
|
||||
instance.loader === 'optifine' ||
|
||||
instance.loader_components.some((component) => component.kind === 'optifine')
|
||||
) {
|
||||
return 'opti_fine'
|
||||
}
|
||||
if (!snapshot) return 'unknown'
|
||||
|
||||
const hasIris = snapshot.items.some(
|
||||
(item) =>
|
||||
(item.provider === 'modrinth' && item.providerProjectId === IRIS_MODRINTH_PROJECT_ID) ||
|
||||
item.content?.provider_refs.some(
|
||||
(reference) =>
|
||||
reference.provider === 'modrinth' && reference.project_id === IRIS_MODRINTH_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
if (hasIris) return 'iris'
|
||||
|
||||
const hasUnresolvedModIdentity = snapshot.items.some(
|
||||
(item) =>
|
||||
item.projectType === 'mod' &&
|
||||
(item.provider !== 'modrinth' || item.providerProjectId === null),
|
||||
)
|
||||
return hasUnresolvedModIdentity ? 'unknown' : 'none'
|
||||
}
|
||||
|
||||
export function newerStableGameVersions(
|
||||
metadata: GameVersionTag[],
|
||||
currentVersion: string,
|
||||
): UpgradeVersionTargets {
|
||||
const currentIndex = metadata.findIndex((version) => version.version === currentVersion)
|
||||
const candidates = currentIndex === -1 ? metadata : metadata.slice(0, currentIndex)
|
||||
return {
|
||||
currentFound: currentIndex !== -1,
|
||||
versions: candidates
|
||||
.filter((version) => version.version_type === 'release' && version.version !== currentVersion)
|
||||
.map((version) => version.version),
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeSelections(solution: InstanceUpgradeSolution) {
|
||||
return solution.selections.reduce(
|
||||
(summary, selection) => {
|
||||
if (selection.action === 'disable') summary.disabled += 1
|
||||
else if (
|
||||
selection.action === 'upgrade' &&
|
||||
selection.targetReleaseId !== null &&
|
||||
selection.targetReleaseId !== selection.currentReleaseId
|
||||
) {
|
||||
summary.updates += 1
|
||||
} else summary.keptOrCompatible += 1
|
||||
return summary
|
||||
},
|
||||
{ updates: 0, keptOrCompatible: 0, disabled: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
export function solutionSummary(solution: InstanceUpgradeSolution): SolutionSummary {
|
||||
const selections = summarizeSelections(solution)
|
||||
return {
|
||||
upgraded: selections.updates,
|
||||
kept: selections.keptOrCompatible,
|
||||
disabled: selections.disabled,
|
||||
dependencyAdditions: solution.dependencyChanges.filter((change) => change.kind === 'add')
|
||||
.length,
|
||||
dependencyUpdates: solution.dependencyChanges.filter((change) => change.kind === 'upgrade')
|
||||
.length,
|
||||
dependencyRemovals: solution.dependencyChanges.filter((change) => change.kind === 'remove')
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmSolutionGroups(solution: InstanceUpgradeSolution): ConfirmSolutionGroups {
|
||||
return {
|
||||
updated: solution.selections.filter(
|
||||
(selection) =>
|
||||
selection.action === 'upgrade' &&
|
||||
selection.targetReleaseId !== null &&
|
||||
selection.targetReleaseId !== selection.currentReleaseId,
|
||||
),
|
||||
kept: solution.selections.filter(
|
||||
(selection) =>
|
||||
selection.action !== 'disable' &&
|
||||
(selection.action !== 'upgrade' ||
|
||||
selection.targetReleaseId === null ||
|
||||
selection.targetReleaseId === selection.currentReleaseId),
|
||||
),
|
||||
disabled: solution.selections.filter((selection) => selection.action === 'disable'),
|
||||
dependencyChanges: solution.dependencyChanges.filter((change) => change.kind !== 'keep'),
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmUpgradeOptions(
|
||||
sharedInstance: boolean,
|
||||
sharedMode: 'direct' | 'copy_and_upgrade' | null,
|
||||
directFullBackupPreference: boolean,
|
||||
): ConfirmUpgradeOptions {
|
||||
const effectiveMode = sharedInstance ? sharedMode : 'direct'
|
||||
return {
|
||||
effectiveMode,
|
||||
createFullBackup: effectiveMode === 'copy_and_upgrade' ? false : directFullBackupPreference,
|
||||
canStart: !sharedInstance || sharedMode !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmSelectionReleaseSlots(
|
||||
selection: InstanceUpgradeSolution['selections'][number],
|
||||
): ConfirmReleaseSlots {
|
||||
return {
|
||||
currentReleaseId: selection.currentReleaseId,
|
||||
targetReleaseId:
|
||||
selection.action === 'upgrade' && selection.targetReleaseId !== selection.currentReleaseId
|
||||
? selection.targetReleaseId
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmDependencyReleaseSlots(
|
||||
change: InstanceUpgradeDependencyChange,
|
||||
): ConfirmReleaseSlots {
|
||||
return {
|
||||
currentReleaseId: change.currentReleaseId,
|
||||
targetReleaseId:
|
||||
change.targetReleaseId !== change.currentReleaseId ? change.targetReleaseId : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveConfirmDependencyReleases(
|
||||
change: InstanceUpgradeDependencyChange,
|
||||
resolveLabel: (releaseId: string | null, slot: 'current' | 'target') => string | null,
|
||||
): ConfirmResolvedReleaseSlots {
|
||||
const releases = confirmDependencyReleaseSlots(change)
|
||||
return {
|
||||
...releases,
|
||||
current: resolveLabel(releases.currentReleaseId, 'current'),
|
||||
target: resolveLabel(releases.targetReleaseId, 'target'),
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmTargetLoaderLabel(
|
||||
loaderLabel: string,
|
||||
loader: InstanceUpgradeTargetEnvironment['modLoader'],
|
||||
version: string | null,
|
||||
automaticLabel: string,
|
||||
): string {
|
||||
if (version) return `${loaderLabel} ${version}`
|
||||
return loader === 'vanilla' ? loaderLabel : `${loaderLabel} (${automaticLabel})`
|
||||
}
|
||||
|
||||
function normalizedConstraints(constraints: InstanceUpgradeFixedConstraint[]) {
|
||||
return constraints
|
||||
.map((constraint) => ({
|
||||
contentId: constraint.contentId,
|
||||
provider: constraint.provider,
|
||||
projectId: constraint.projectId,
|
||||
versionId: constraint.versionId,
|
||||
}))
|
||||
.sort((left, right) => left.contentId.localeCompare(right.contentId))
|
||||
}
|
||||
|
||||
export function customConstraintsEqual(
|
||||
left: InstanceUpgradeFixedConstraint[],
|
||||
right: InstanceUpgradeFixedConstraint[],
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify(normalizedConstraints(left)) === JSON.stringify(normalizedConstraints(right))
|
||||
)
|
||||
}
|
||||
|
||||
export function setFixedConstraint(
|
||||
constraints: InstanceUpgradeFixedConstraint[],
|
||||
constraint: InstanceUpgradeFixedConstraint | null,
|
||||
contentId: string,
|
||||
): InstanceUpgradeFixedConstraint[] {
|
||||
const withoutContent = constraints.filter((current) => current.contentId !== contentId)
|
||||
return normalizedConstraints(constraint ? [...withoutContent, constraint] : withoutContent)
|
||||
}
|
||||
|
||||
export function editableUpgradeRoots(plan: InstanceUpgradePlan) {
|
||||
return plan.items.filter(
|
||||
(item) =>
|
||||
!item.autoDependency &&
|
||||
(item.provider === 'modrinth' || item.provider === 'curseforge') &&
|
||||
item.projectId !== null &&
|
||||
(item.candidateReleaseIds.length > 0 ||
|
||||
plan.customConstraints.some((constraint) => constraint.contentId === item.contentId)),
|
||||
)
|
||||
}
|
||||
|
||||
export function compatibilitySummary(plan: InstanceUpgradePlan): CompatibilitySummary {
|
||||
const content = plan.selectedSolution
|
||||
? summarizeSelections(plan.selectedSolution)
|
||||
: plan.items.reduce(
|
||||
(summary, item) => {
|
||||
if (item.resolution.action === 'disable') summary.disabled += 1
|
||||
else if (item.status === 'upgrade_available') summary.updates += 1
|
||||
else if (item.status === 'already_compatible' || item.resolution.action === 'keep') {
|
||||
summary.keptOrCompatible += 1
|
||||
}
|
||||
return summary
|
||||
},
|
||||
{ updates: 0, keptOrCompatible: 0, disabled: 0 },
|
||||
)
|
||||
const dependencyChanges = (
|
||||
plan.selectedSolution?.dependencyChanges ?? plan.dependencyChanges
|
||||
).filter((change) => change.kind !== 'keep').length
|
||||
|
||||
return {
|
||||
...content,
|
||||
dependencyChanges,
|
||||
needsAttention: plan.blockingIssues.length,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildUpgradeDisplayNames } from '../../../../../../packages/ui/src/utils/loaders.ts'
|
||||
|
||||
const input = {
|
||||
sourceName: '1.21.8-Fabric 0.18.4',
|
||||
sourceLoader: 'fabric',
|
||||
sourceGameVersion: '1.21.8',
|
||||
sourceLoaderVersion: '0.18.4',
|
||||
targetLoader: 'fabric',
|
||||
targetGameVersion: '1.21.9',
|
||||
targetLoaderVersion: '0.18.5',
|
||||
backupName: '1.21.8-Fabric 0.18.4(升级前备份)',
|
||||
customCopyName: '1.21.8-Fabric 0.18.4(升级副本)',
|
||||
}
|
||||
|
||||
test('default source name renames direct target and names copy for target environment', () => {
|
||||
assert.deepEqual(buildUpgradeDisplayNames(input), {
|
||||
backup: input.backupName,
|
||||
copy: '1.21.9-Fabric 0.18.5',
|
||||
upgradedTarget: '1.21.9-Fabric 0.18.5',
|
||||
shouldAutoRename: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('custom source name stays unchanged while copy receives localized suffix', () => {
|
||||
assert.deepEqual(buildUpgradeDisplayNames({ ...input, sourceName: 'My survival instance' }), {
|
||||
backup: input.backupName,
|
||||
copy: input.customCopyName,
|
||||
upgradedTarget: null,
|
||||
shouldAutoRename: false,
|
||||
})
|
||||
})
|
||||
63
apps/app-frontend/src/pages/instance/upgrade/entry.test.ts
Normal file
63
apps/app-frontend/src/pages/instance/upgrade/entry.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { isActiveUpgradeJobForInstance, isUnmanagedUpgradeEligible } from './entry.ts'
|
||||
|
||||
const instance = (link: GameInstance['link'] = null): GameInstance => ({
|
||||
id: 'instance',
|
||||
path: 'path',
|
||||
install_stage: 'installed',
|
||||
launcher_feature_version: '1',
|
||||
name: 'Instance',
|
||||
game_version: '1.21.8',
|
||||
loader: 'fabric',
|
||||
loader_components: [],
|
||||
groups: [],
|
||||
link,
|
||||
update_channel: 'release',
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
submitted_time_played: 0,
|
||||
recent_time_played: 0,
|
||||
hooks: {},
|
||||
})
|
||||
|
||||
test('eligibility allows local/shared and excludes managed packs', () => {
|
||||
assert.equal(isUnmanagedUpgradeEligible(instance()), true)
|
||||
assert.equal(
|
||||
isUnmanagedUpgradeEligible(instance({ type: 'shared_instance', shared_instance_id: 'shared' })),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isUnmanagedUpgradeEligible(
|
||||
instance({ type: 'modrinth_modpack', project_id: 'p', version_id: 'v' }),
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(isUnmanagedUpgradeEligible({ ...instance(), install_stage: 'not_installed' }), false)
|
||||
})
|
||||
|
||||
test('active upgrade job ownership is exact', () => {
|
||||
const job = {
|
||||
kind: 'upgrade_unmanaged_instance',
|
||||
status: 'running',
|
||||
instance_id: 'instance',
|
||||
} as InstallJobSnapshot
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'instance'), true)
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'other'), false)
|
||||
assert.equal(isActiveUpgradeJobForInstance({ ...job, status: 'succeeded' }, 'instance'), false)
|
||||
})
|
||||
|
||||
test('active copy upgrade belongs to original source instance', () => {
|
||||
const job = {
|
||||
kind: 'upgrade_unmanaged_instance',
|
||||
status: 'running',
|
||||
instance_id: 'copy',
|
||||
source_instance_id: 'source',
|
||||
} as InstallJobSnapshot
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'source'), true)
|
||||
assert.equal(isActiveUpgradeJobForInstance(job, 'copy'), false)
|
||||
})
|
||||
23
apps/app-frontend/src/pages/instance/upgrade/entry.ts
Normal file
23
apps/app-frontend/src/pages/instance/upgrade/entry.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export function isUnmanagedUpgradeEligible(instance: GameInstance): boolean {
|
||||
return (
|
||||
instance.install_stage === 'installed' &&
|
||||
Boolean(instance.game_version && instance.loader) &&
|
||||
(instance.link == null ||
|
||||
instance.link.type === 'shared_instance' ||
|
||||
Boolean(instance.symlink_target))
|
||||
)
|
||||
}
|
||||
|
||||
export function isActiveUpgradeJobForInstance(
|
||||
job: InstallJobSnapshot,
|
||||
instanceId: string,
|
||||
): boolean {
|
||||
return (
|
||||
job.kind === 'upgrade_unmanaged_instance' &&
|
||||
['queued', 'running', 'canceling', 'waiting_for_user'].includes(job.status) &&
|
||||
(job.source_instance_id ?? job.instance_id) === instanceId
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
bulkResolutionAction,
|
||||
filterBulkResolutionIds,
|
||||
initialCustomizeStrategy,
|
||||
UPGRADE_ACTIVE_STEPS,
|
||||
upgradeControlEnabled,
|
||||
upgradeProgressModel,
|
||||
} from './flow-controls.ts'
|
||||
|
||||
test('registered upgrade control reads live ref values without re-registration', () => {
|
||||
const canPlan = ref(false)
|
||||
const control = computed(() => canPlan.value)
|
||||
assert.equal(upgradeControlEnabled(control), false)
|
||||
canPlan.value = true
|
||||
assert.equal(upgradeControlEnabled(control), true)
|
||||
})
|
||||
|
||||
test('missing controls remain disabled', () => {
|
||||
assert.equal(upgradeControlEnabled(undefined), false)
|
||||
})
|
||||
|
||||
test('upgrade progress maps five active routes and terminal result', () => {
|
||||
assert.equal(UPGRADE_ACTIVE_STEPS.length, 5)
|
||||
for (const [index, route] of UPGRADE_ACTIVE_STEPS.entries()) {
|
||||
assert.deepEqual(upgradeProgressModel(`/instance/example/upgrade/${route}`), {
|
||||
currentIndex: index,
|
||||
complete: false,
|
||||
steps: UPGRADE_ACTIVE_STEPS,
|
||||
})
|
||||
}
|
||||
assert.equal(upgradeProgressModel('/instance/example/upgrade/result').complete, true)
|
||||
})
|
||||
|
||||
test('customize strategy prefers flow UI state over selected backend solution', () => {
|
||||
assert.equal(initialCustomizeStrategy('custom', 'newest', 'custom'), 'custom')
|
||||
assert.equal(initialCustomizeStrategy(null, 'minimal_change', 'custom'), 'minimal_change')
|
||||
assert.equal(initialCustomizeStrategy(null, null, 'custom'), 'custom')
|
||||
})
|
||||
|
||||
test('bulk resolution state and no-op filtering use authoritative actions', () => {
|
||||
assert.equal(bulkResolutionAction(['keep', 'keep']), 'keep')
|
||||
assert.equal(bulkResolutionAction(['disable', 'disable']), 'disable')
|
||||
assert.equal(bulkResolutionAction(['keep', 'disable']), null)
|
||||
assert.deepEqual(
|
||||
filterBulkResolutionIds(
|
||||
[
|
||||
{ contentId: 'a', action: 'keep' },
|
||||
{ contentId: 'b', action: 'disable' },
|
||||
],
|
||||
'keep',
|
||||
),
|
||||
['b'],
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,56 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
export function upgradeControlEnabled(value: MaybeRef<boolean> | undefined): boolean {
|
||||
return toValue(value ?? false)
|
||||
}
|
||||
|
||||
export const UPGRADE_ACTIVE_STEPS = [
|
||||
'upgrade',
|
||||
'compatibility',
|
||||
'customize',
|
||||
'confirm',
|
||||
'progress',
|
||||
] as const
|
||||
|
||||
export interface UpgradeProgressModel {
|
||||
currentIndex: number
|
||||
complete: boolean
|
||||
steps: typeof UPGRADE_ACTIVE_STEPS
|
||||
}
|
||||
|
||||
export function upgradeProgressModel(path: string): UpgradeProgressModel {
|
||||
const routeStep = path.split('/').filter(Boolean).at(-1) ?? 'upgrade'
|
||||
const complete = routeStep === 'result'
|
||||
const index = UPGRADE_ACTIVE_STEPS.indexOf(routeStep as (typeof UPGRADE_ACTIVE_STEPS)[number])
|
||||
return {
|
||||
currentIndex: complete ? UPGRADE_ACTIVE_STEPS.length - 1 : Math.max(index, 0),
|
||||
complete,
|
||||
steps: UPGRADE_ACTIVE_STEPS,
|
||||
}
|
||||
}
|
||||
|
||||
export function initialCustomizeStrategy<T>(
|
||||
flowStrategy: T | null | undefined,
|
||||
selectedStrategy: T | null | undefined,
|
||||
defaultStrategy: T,
|
||||
): T {
|
||||
return flowStrategy ?? selectedStrategy ?? defaultStrategy
|
||||
}
|
||||
|
||||
export function bulkResolutionAction(
|
||||
actions: Array<'upgrade' | 'keep' | 'disable'>,
|
||||
): 'keep' | 'disable' | null {
|
||||
if (!actions.length) return null
|
||||
const unique = new Set(actions)
|
||||
return unique.size === 1 && (unique.has('keep') || unique.has('disable'))
|
||||
? ([...unique][0] as 'keep' | 'disable')
|
||||
: null
|
||||
}
|
||||
|
||||
export function filterBulkResolutionIds(
|
||||
items: Array<{ contentId: string; action: 'upgrade' | 'keep' | 'disable' }>,
|
||||
action: 'keep' | 'disable',
|
||||
): string[] {
|
||||
return items.filter((item) => item.action !== action).map((item) => item.contentId)
|
||||
}
|
||||
97
apps/app-frontend/src/pages/instance/upgrade/flow.test.ts
Normal file
97
apps/app-frontend/src/pages/instance/upgrade/flow.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { InstanceUpgradePlan, InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
attachUpgradeJobToFlow,
|
||||
type InstanceUpgradeFlow,
|
||||
isUpgradeRouteAvailable,
|
||||
isUpgradeRouteRecoveryPending,
|
||||
upgradeDownloadsLocation,
|
||||
upgradeProgressDestination,
|
||||
} from './flow.ts'
|
||||
|
||||
function selectionFlow(plan: InstanceUpgradePlan | null): InstanceUpgradeFlow {
|
||||
return { plan: ref(plan) } as InstanceUpgradeFlow
|
||||
}
|
||||
|
||||
test('selection route requires an unblocked plan with a selected solution', () => {
|
||||
const selectedSolution = { kind: 'newest', selections: [], dependencyChanges: [], warnings: [] }
|
||||
assert.equal(isUpgradeRouteAvailable('selection', selectionFlow(null)), false)
|
||||
assert.equal(
|
||||
isUpgradeRouteAvailable(
|
||||
'selection',
|
||||
selectionFlow({ blockingIssues: [], selectedSolution: null } as InstanceUpgradePlan),
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isUpgradeRouteAvailable(
|
||||
'selection',
|
||||
selectionFlow({
|
||||
blockingIssues: [{ code: 'dependency_conflict' }],
|
||||
selectedSolution,
|
||||
} as InstanceUpgradePlan),
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isUpgradeRouteAvailable(
|
||||
'selection',
|
||||
selectionFlow({ blockingIssues: [], selectedSolution } as InstanceUpgradePlan),
|
||||
),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('upgrade execution and Progress recovery target focused Downloads', () => {
|
||||
assert.deepEqual(upgradeDownloadsLocation('job/a'), {
|
||||
path: '/downloads',
|
||||
query: { job: 'job/a' },
|
||||
})
|
||||
assert.equal(upgradeProgressDestination('loading', null, 'instance/a'), null)
|
||||
assert.deepEqual(upgradeProgressDestination('ready', 'job/a', 'instance/a'), {
|
||||
path: '/downloads',
|
||||
query: { job: 'job/a' },
|
||||
})
|
||||
assert.deepEqual(upgradeProgressDestination('ready', null, 'instance/a'), {
|
||||
path: '/instance/instance%2Fa/upgrade',
|
||||
})
|
||||
})
|
||||
|
||||
test('accepted upgrade job sets ownership, preserves backend result, and returns Downloads target', () => {
|
||||
let jobId: string | null = null
|
||||
let result: unknown = null
|
||||
const location = attachUpgradeJobToFlow(
|
||||
{
|
||||
setJob: (value) => (jobId = value),
|
||||
setResult: (value) => (result = value),
|
||||
},
|
||||
{
|
||||
job_id: 'job-a',
|
||||
status: 'succeeded',
|
||||
upgrade_result: { planId: 'plan-a' } as InstanceUpgradeResult,
|
||||
} as InstallJobSnapshot,
|
||||
)
|
||||
assert.equal(jobId, 'job-a')
|
||||
assert.deepEqual(result, { planId: 'plan-a' })
|
||||
assert.deepEqual(location, { path: '/downloads', query: { job: 'job-a' } })
|
||||
})
|
||||
|
||||
test('job route waits only while persisted job recovery is loading', () => {
|
||||
const loading = {
|
||||
jobRecoveryState: ref('loading'),
|
||||
activeJobId: ref(null),
|
||||
} as InstanceUpgradeFlow
|
||||
assert.equal(isUpgradeRouteRecoveryPending('job', loading), true)
|
||||
assert.equal(isUpgradeRouteRecoveryPending('result', loading), true)
|
||||
loading.jobRecoveryState.value = 'ready'
|
||||
assert.equal(isUpgradeRouteRecoveryPending('job', loading), false)
|
||||
assert.equal(isUpgradeRouteAvailable('job', loading), false)
|
||||
loading.activeJobId.value = 'job-a'
|
||||
assert.equal(isUpgradeRouteAvailable('job', loading), true)
|
||||
})
|
||||
236
apps/app-frontend/src/pages/instance/upgrade/flow.ts
Normal file
236
apps/app-frontend/src/pages/instance/upgrade/flow.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import type { ComputedRef, InjectionKey, MaybeRef, Ref } from 'vue'
|
||||
import { computed, inject, provide, ref } from 'vue'
|
||||
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type {
|
||||
InstanceUpgradeIssue,
|
||||
InstanceUpgradePlan,
|
||||
InstanceUpgradeResult,
|
||||
InstanceUpgradeSolutionKind,
|
||||
InstanceUpgradeTargetEnvironment,
|
||||
SharedUpgradeMode,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export type UpgradeRouteRequirement = 'plan' | 'unblocked-plan' | 'selection' | 'job' | 'result'
|
||||
export type UpgradeJobRecoveryState = 'idle' | 'loading' | 'ready'
|
||||
|
||||
export function upgradeDownloadsLocation(jobId: string) {
|
||||
return { path: '/downloads', query: { job: jobId } } as const
|
||||
}
|
||||
|
||||
export function upgradeProgressDestination(
|
||||
recoveryState: UpgradeJobRecoveryState,
|
||||
jobId: string | null,
|
||||
instanceId: string,
|
||||
) {
|
||||
if (recoveryState !== 'ready') return null
|
||||
return jobId
|
||||
? upgradeDownloadsLocation(jobId)
|
||||
: { path: `/instance/${encodeURIComponent(instanceId)}/upgrade` }
|
||||
}
|
||||
|
||||
export function attachUpgradeJobToFlow(
|
||||
flow: Pick<InstanceUpgradeFlow, 'setJob' | 'setResult'>,
|
||||
job: InstallJobSnapshot,
|
||||
) {
|
||||
flow.setJob(job.job_id)
|
||||
if (job.status === 'succeeded' && job.upgrade_result) {
|
||||
flow.setResult(job.upgrade_result)
|
||||
}
|
||||
return upgradeDownloadsLocation(job.job_id)
|
||||
}
|
||||
|
||||
export interface InstanceUpgradeFlow {
|
||||
instance: Readonly<Ref<GameInstance>>
|
||||
instanceId: Readonly<Ref<string>>
|
||||
targetEnvironment: Ref<InstanceUpgradeTargetEnvironment | null>
|
||||
plan: Ref<InstanceUpgradePlan | null>
|
||||
selectedSolutionKind: ComputedRef<InstanceUpgradeSolutionKind | null>
|
||||
createFullBackup: Ref<boolean>
|
||||
directFullBackupPreference: Ref<boolean>
|
||||
sharedUpgradeMode: Ref<SharedUpgradeMode | null>
|
||||
activeJobId: Ref<string | null>
|
||||
jobRecoveryState: Ref<UpgradeJobRecoveryState>
|
||||
result: Ref<InstanceUpgradeResult | null>
|
||||
initialBlockingPlanId: Ref<string | null>
|
||||
initialBlockingIssues: Ref<Record<string, InstanceUpgradeIssue[]>>
|
||||
customizeActiveStrategy: Ref<InstanceUpgradeSolutionKind | null>
|
||||
busy: Ref<boolean>
|
||||
error: Ref<unknown | null>
|
||||
reset: () => void
|
||||
clearPlan: () => void
|
||||
setTargetEnvironment: (environment: InstanceUpgradeTargetEnvironment | null) => void
|
||||
setPlan: (plan: InstanceUpgradePlan | null) => void
|
||||
setJob: (jobId: string | null) => void
|
||||
setJobRecoveryState: (state: UpgradeJobRecoveryState) => void
|
||||
setResult: (result: InstanceUpgradeResult | null) => void
|
||||
hydrate: (snapshot: UpgradeFlowSnapshot) => void
|
||||
controls: Ref<UpgradeStepControls | null>
|
||||
registerStepControls: (controls: UpgradeStepControls | null) => void
|
||||
}
|
||||
|
||||
export interface UpgradeStepControls {
|
||||
canNext: MaybeRef<boolean>
|
||||
busy?: MaybeRef<boolean>
|
||||
nextLabel: string
|
||||
onNext: () => void | Promise<void>
|
||||
onBack: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface UpgradeFlowSnapshot {
|
||||
instanceId: string
|
||||
returnFullPath: string
|
||||
targetEnvironment: InstanceUpgradeTargetEnvironment | null
|
||||
plan: InstanceUpgradePlan | null
|
||||
createFullBackup: boolean
|
||||
directFullBackupPreference?: boolean
|
||||
sharedUpgradeMode: SharedUpgradeMode | null
|
||||
activeJobId: string | null
|
||||
result: InstanceUpgradeResult | null
|
||||
initialBlockingPlanId?: string | null
|
||||
initialBlockingIssues?: Record<string, InstanceUpgradeIssue[]>
|
||||
customizeActiveStrategy?: InstanceUpgradeSolutionKind | null
|
||||
scrollTop?: number
|
||||
}
|
||||
|
||||
export const INSTANCE_UPGRADE_FLOW_KEY: InjectionKey<InstanceUpgradeFlow> =
|
||||
Symbol('instance-upgrade-flow')
|
||||
|
||||
export function provideUpgradeFlow(flow: InstanceUpgradeFlow) {
|
||||
provide(INSTANCE_UPGRADE_FLOW_KEY, flow)
|
||||
}
|
||||
|
||||
export function provideInstanceUpgradeFlow(
|
||||
instance: Readonly<Ref<GameInstance>>,
|
||||
): InstanceUpgradeFlow {
|
||||
const instanceId = computed(() => instance.value.id)
|
||||
const targetEnvironment = ref<InstanceUpgradeTargetEnvironment | null>(null)
|
||||
const plan = ref<InstanceUpgradePlan | null>(null)
|
||||
const createFullBackup = ref(true)
|
||||
const directFullBackupPreference = ref(true)
|
||||
const sharedUpgradeMode = ref<SharedUpgradeMode | null>(null)
|
||||
const activeJobId = ref<string | null>(null)
|
||||
const jobRecoveryState = ref<UpgradeJobRecoveryState>('idle')
|
||||
const result = ref<InstanceUpgradeResult | null>(null)
|
||||
const initialBlockingPlanId = ref<string | null>(null)
|
||||
const initialBlockingIssues = ref<Record<string, InstanceUpgradeIssue[]>>({})
|
||||
const customizeActiveStrategy = ref<InstanceUpgradeSolutionKind | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref<unknown | null>(null)
|
||||
const selectedSolutionKind = computed(() => plan.value?.selectedSolution?.kind ?? null)
|
||||
const controls = ref<UpgradeStepControls | null>(null)
|
||||
|
||||
function clearPlan() {
|
||||
plan.value = null
|
||||
initialBlockingPlanId.value = null
|
||||
initialBlockingIssues.value = {}
|
||||
customizeActiveStrategy.value = null
|
||||
activeJobId.value = null
|
||||
result.value = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
targetEnvironment.value = null
|
||||
clearPlan()
|
||||
createFullBackup.value = true
|
||||
directFullBackupPreference.value = true
|
||||
sharedUpgradeMode.value = null
|
||||
busy.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function hydrate(snapshot: UpgradeFlowSnapshot) {
|
||||
if (snapshot.instanceId !== instance.value.id) return
|
||||
targetEnvironment.value = snapshot.targetEnvironment
|
||||
plan.value = snapshot.plan
|
||||
createFullBackup.value = snapshot.createFullBackup
|
||||
directFullBackupPreference.value = snapshot.directFullBackupPreference ?? true
|
||||
sharedUpgradeMode.value = snapshot.sharedUpgradeMode
|
||||
activeJobId.value = snapshot.activeJobId
|
||||
result.value = snapshot.result
|
||||
initialBlockingPlanId.value = snapshot.initialBlockingPlanId ?? null
|
||||
initialBlockingIssues.value = snapshot.initialBlockingIssues ?? {}
|
||||
customizeActiveStrategy.value = snapshot.customizeActiveStrategy ?? null
|
||||
}
|
||||
|
||||
const flow: InstanceUpgradeFlow = {
|
||||
instance,
|
||||
instanceId,
|
||||
targetEnvironment,
|
||||
plan,
|
||||
selectedSolutionKind,
|
||||
createFullBackup,
|
||||
directFullBackupPreference,
|
||||
sharedUpgradeMode,
|
||||
activeJobId,
|
||||
jobRecoveryState,
|
||||
result,
|
||||
initialBlockingPlanId,
|
||||
initialBlockingIssues,
|
||||
customizeActiveStrategy,
|
||||
busy,
|
||||
error,
|
||||
reset,
|
||||
clearPlan,
|
||||
setTargetEnvironment: (environment) => (targetEnvironment.value = environment),
|
||||
setPlan: (nextPlan) => {
|
||||
if (nextPlan?.id !== plan.value?.id) {
|
||||
initialBlockingPlanId.value = null
|
||||
initialBlockingIssues.value = {}
|
||||
customizeActiveStrategy.value = null
|
||||
sharedUpgradeMode.value = null
|
||||
createFullBackup.value = true
|
||||
directFullBackupPreference.value = true
|
||||
}
|
||||
plan.value = nextPlan
|
||||
},
|
||||
setJob: (jobId) => (activeJobId.value = jobId),
|
||||
setJobRecoveryState: (state) => (jobRecoveryState.value = state),
|
||||
setResult: (nextResult) => (result.value = nextResult),
|
||||
hydrate,
|
||||
controls,
|
||||
registerStepControls: (next) => (controls.value = next),
|
||||
}
|
||||
provideUpgradeFlow(flow)
|
||||
return flow
|
||||
}
|
||||
|
||||
export function isUpgradeRouteRecoveryPending(
|
||||
requirement: UpgradeRouteRequirement | undefined,
|
||||
flow: InstanceUpgradeFlow,
|
||||
): boolean {
|
||||
return (
|
||||
(requirement === 'job' || requirement === 'result') && flow.jobRecoveryState.value === 'loading'
|
||||
)
|
||||
}
|
||||
|
||||
export function useInstanceUpgradeFlow(): InstanceUpgradeFlow {
|
||||
const flow = inject(INSTANCE_UPGRADE_FLOW_KEY)
|
||||
if (!flow) throw new Error('Instance upgrade flow was not provided')
|
||||
return flow
|
||||
}
|
||||
|
||||
export function isUpgradeRouteAvailable(
|
||||
requirement: UpgradeRouteRequirement | undefined,
|
||||
flow: InstanceUpgradeFlow,
|
||||
): boolean {
|
||||
switch (requirement) {
|
||||
case 'plan':
|
||||
return flow.plan.value !== null
|
||||
case 'unblocked-plan':
|
||||
return flow.plan.value !== null && flow.plan.value.blockingIssues.length === 0
|
||||
case 'selection':
|
||||
return (
|
||||
flow.plan.value !== null &&
|
||||
flow.plan.value.blockingIssues.length === 0 &&
|
||||
flow.plan.value.selectedSolution !== null
|
||||
)
|
||||
case 'job':
|
||||
return flow.activeJobId.value !== null
|
||||
case 'result':
|
||||
return flow.result.value !== null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
122
apps/app-frontend/src/pages/instance/upgrade/install-job-core.ts
Normal file
122
apps/app-frontend/src/pages/instance/upgrade/install-job-core.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
import type { InstanceUpgradeDisplayNames, SharedUpgradeMode } from '@/helpers/instance-upgrade'
|
||||
|
||||
const RECOVERABLE_UPGRADE_STATUSES = new Set<InstallJobStatus>([
|
||||
'queued',
|
||||
'running',
|
||||
'canceling',
|
||||
'waiting_for_user',
|
||||
])
|
||||
|
||||
export type InstallJobInstanceIdResolver = (job: InstallJobSnapshot) => string | null
|
||||
|
||||
export interface UpgradeJobSelectionContext {
|
||||
knownJobId?: string | null
|
||||
continuation?: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionRequest {
|
||||
instanceId: string
|
||||
planId: string
|
||||
createFullBackup: boolean
|
||||
sharedUpgradeMode: SharedUpgradeMode
|
||||
displayNames: InstanceUpgradeDisplayNames
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionLock {
|
||||
value: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionResult {
|
||||
job: InstallJobSnapshot
|
||||
attached: boolean
|
||||
}
|
||||
|
||||
export interface UpgradeSubmissionDependencies {
|
||||
listJobs: (includeFinished: boolean) => Promise<InstallJobSnapshot[]>
|
||||
execute: (
|
||||
planId: string,
|
||||
createFullBackup: boolean,
|
||||
sharedUpgradeMode: SharedUpgradeMode,
|
||||
displayNames: InstanceUpgradeDisplayNames,
|
||||
) => Promise<InstallJobSnapshot>
|
||||
instanceIdOf: InstallJobInstanceIdResolver
|
||||
}
|
||||
|
||||
export function isRecoverableUpgradeStatus(status: InstallJobStatus): boolean {
|
||||
return RECOVERABLE_UPGRADE_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export function isInstanceUpgradeJobWith(
|
||||
job: InstallJobSnapshot,
|
||||
instanceId: string,
|
||||
instanceIdOf: InstallJobInstanceIdResolver,
|
||||
): boolean {
|
||||
if (job.kind !== 'upgrade_unmanaged_instance') return false
|
||||
return (job.source_instance_id ?? instanceIdOf(job)) === instanceId
|
||||
}
|
||||
|
||||
function compareJobFreshness(a: InstallJobSnapshot, b: InstallJobSnapshot): number {
|
||||
return (
|
||||
b.modified.localeCompare(a.modified) ||
|
||||
b.created.localeCompare(a.created) ||
|
||||
b.job_id.localeCompare(a.job_id)
|
||||
)
|
||||
}
|
||||
|
||||
export function selectRecoverableUpgradeJobWith(
|
||||
jobs: InstallJobSnapshot[],
|
||||
instanceId: string,
|
||||
context: UpgradeJobSelectionContext,
|
||||
instanceIdOf: InstallJobInstanceIdResolver,
|
||||
): InstallJobSnapshot | null {
|
||||
const matching = jobs.filter((job) => isInstanceUpgradeJobWith(job, instanceId, instanceIdOf))
|
||||
if (context.knownJobId) {
|
||||
const known = matching.find((job) => job.job_id === context.knownJobId)
|
||||
if (known) return known
|
||||
}
|
||||
|
||||
const active = matching.filter((job) => isRecoverableUpgradeStatus(job.status))
|
||||
if (active.length) return [...active].sort(compareJobFreshness)[0]
|
||||
|
||||
if (!context.continuation) return null
|
||||
const completed = matching.filter(
|
||||
(job) =>
|
||||
job.status === 'succeeded' && job.upgrade_result !== null && job.upgrade_result !== undefined,
|
||||
)
|
||||
return completed.length ? [...completed].sort(compareJobFreshness)[0] : null
|
||||
}
|
||||
|
||||
export async function submitInstanceUpgradeWith(
|
||||
request: UpgradeSubmissionRequest,
|
||||
lock: UpgradeSubmissionLock,
|
||||
dependencies: UpgradeSubmissionDependencies,
|
||||
): Promise<UpgradeSubmissionResult | null> {
|
||||
if (lock.value) return null
|
||||
lock.value = true
|
||||
try {
|
||||
const jobs = await dependencies.listJobs(false)
|
||||
const active = selectRecoverableUpgradeJobWith(
|
||||
jobs,
|
||||
request.instanceId,
|
||||
{},
|
||||
dependencies.instanceIdOf,
|
||||
)
|
||||
if (active) return { job: active, attached: true }
|
||||
|
||||
const job = await dependencies.execute(
|
||||
request.planId,
|
||||
request.createFullBackup,
|
||||
request.sharedUpgradeMode,
|
||||
request.displayNames,
|
||||
)
|
||||
if (!isInstanceUpgradeJobWith(job, request.instanceId, dependencies.instanceIdOf)) {
|
||||
throw new Error(
|
||||
'Upgrade execution returned an Install Job for a different instance or job kind',
|
||||
)
|
||||
}
|
||||
return { job, attached: false }
|
||||
} finally {
|
||||
lock.value = false
|
||||
}
|
||||
}
|
||||
273
apps/app-frontend/src/pages/instance/upgrade/install-job.test.ts
Normal file
273
apps/app-frontend/src/pages/instance/upgrade/install-job.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
import type { InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
isInstanceUpgradeJobWith,
|
||||
isRecoverableUpgradeStatus,
|
||||
selectRecoverableUpgradeJobWith,
|
||||
submitInstanceUpgradeWith,
|
||||
} from './install-job-core.ts'
|
||||
|
||||
function job(
|
||||
jobId: string,
|
||||
status: InstallJobStatus,
|
||||
options: {
|
||||
instanceId?: string
|
||||
sourceInstanceId?: string | null
|
||||
kind?: InstallJobSnapshot['kind']
|
||||
modified?: string
|
||||
executionMode?: InstallJobSnapshot['execution_mode']
|
||||
result?: InstanceUpgradeResult | null
|
||||
} = {},
|
||||
): InstallJobSnapshot {
|
||||
return {
|
||||
job_id: jobId,
|
||||
instance_id: options.instanceId ?? 'instance-a',
|
||||
source_instance_id: options.sourceInstanceId,
|
||||
kind: options.kind ?? 'upgrade_unmanaged_instance',
|
||||
status,
|
||||
execution_mode: options.executionMode ?? 'normal',
|
||||
target: { type: 'existing_instance', instance_id: options.instanceId ?? 'instance-a' },
|
||||
modified: options.modified ?? '2026-08-22T10:00:00Z',
|
||||
created: '2026-08-22T09:00:00Z',
|
||||
upgrade_result: options.result,
|
||||
} as InstallJobSnapshot
|
||||
}
|
||||
|
||||
const result = { planId: 'plan-a' } as InstanceUpgradeResult
|
||||
const instanceIdOf = (candidate: InstallJobSnapshot) =>
|
||||
candidate.instance_id ?? candidate.target.instance_id ?? null
|
||||
|
||||
test('upgrade job ownership requires matching kind and instance identity', () => {
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(job('correct', 'running'), 'instance-a', instanceIdOf),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(
|
||||
job('wrong-kind', 'running', { kind: 'install_content' }),
|
||||
'instance-a',
|
||||
instanceIdOf,
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(
|
||||
job('wrong-instance', 'running', { instanceId: 'instance-b' }),
|
||||
'instance-a',
|
||||
instanceIdOf,
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('copy upgrade ownership follows source instance, not target instance', () => {
|
||||
const copy = job('copy', 'queued', {
|
||||
instanceId: 'copy-target',
|
||||
sourceInstanceId: 'instance-a',
|
||||
})
|
||||
assert.equal(isInstanceUpgradeJobWith(copy, 'instance-a', instanceIdOf), true)
|
||||
assert.equal(isInstanceUpgradeJobWith(copy, 'copy-target', instanceIdOf), false)
|
||||
assert.equal(
|
||||
isInstanceUpgradeJobWith(
|
||||
job('unrelated', 'queued', { sourceInstanceId: 'instance-c' }),
|
||||
'instance-a',
|
||||
instanceIdOf,
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('active recovery includes waiting and recovery validation and chooses freshest job', () => {
|
||||
assert.equal(isRecoverableUpgradeStatus('waiting_for_user'), true)
|
||||
const selected = selectRecoverableUpgradeJobWith(
|
||||
[
|
||||
job('older', 'running', { modified: '2026-08-22T10:00:00Z' }),
|
||||
job('newer', 'waiting_for_user', {
|
||||
modified: '2026-08-22T11:00:00Z',
|
||||
executionMode: 'recovery_validation',
|
||||
}),
|
||||
],
|
||||
'instance-a',
|
||||
{},
|
||||
instanceIdOf,
|
||||
)
|
||||
assert.equal(selected?.job_id, 'newer')
|
||||
})
|
||||
|
||||
test('ordinary entry ignores old success while continuation recovers backend result', () => {
|
||||
const succeeded = job('succeeded', 'succeeded', { result })
|
||||
assert.equal(selectRecoverableUpgradeJobWith([succeeded], 'instance-a', {}, instanceIdOf), null)
|
||||
assert.equal(
|
||||
selectRecoverableUpgradeJobWith([succeeded], 'instance-a', { continuation: true }, instanceIdOf)
|
||||
?.upgrade_result,
|
||||
result,
|
||||
)
|
||||
})
|
||||
|
||||
test('known terminal job preserves flow ownership', () => {
|
||||
const failed = job('known', 'failed')
|
||||
assert.equal(
|
||||
selectRecoverableUpgradeJobWith([failed], 'instance-a', { knownJobId: 'known' }, instanceIdOf)
|
||||
?.job_id,
|
||||
'known',
|
||||
)
|
||||
})
|
||||
|
||||
function submissionDependencies(calls: unknown[][], jobs: InstallJobSnapshot[] = []) {
|
||||
return {
|
||||
instanceIdOf,
|
||||
listJobs: async () => jobs,
|
||||
execute: async (
|
||||
planId: string,
|
||||
backup: boolean,
|
||||
mode: 'direct' | 'copy_and_upgrade',
|
||||
names: typeof displayNames,
|
||||
) => {
|
||||
calls.push([planId, backup, mode, names])
|
||||
return job(`job-${calls.length}`, 'queued')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const displayNames = {
|
||||
backup: 'Backup',
|
||||
copy: 'Copy',
|
||||
upgradedTarget: 'Target',
|
||||
shouldAutoRename: false,
|
||||
}
|
||||
|
||||
test('normal, shared direct, and copy submissions pass exact execution parameters', async () => {
|
||||
const calls: unknown[][] = []
|
||||
const dependencies = submissionDependencies(calls)
|
||||
for (const request of [
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'normal',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct' as const,
|
||||
displayNames,
|
||||
},
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'shared-direct',
|
||||
createFullBackup: false,
|
||||
sharedUpgradeMode: 'direct' as const,
|
||||
displayNames,
|
||||
},
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'copy',
|
||||
createFullBackup: false,
|
||||
sharedUpgradeMode: 'copy_and_upgrade' as const,
|
||||
displayNames,
|
||||
},
|
||||
]) {
|
||||
await submitInstanceUpgradeWith(request, { value: false }, dependencies)
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['normal', true, 'direct', displayNames],
|
||||
['shared-direct', false, 'direct', displayNames],
|
||||
['copy', false, 'copy_and_upgrade', displayNames],
|
||||
])
|
||||
})
|
||||
|
||||
test('synchronous lock prevents double submission', async () => {
|
||||
let releaseList: (() => void) | undefined
|
||||
let executeCalls = 0
|
||||
const lock = { value: false }
|
||||
const dependencies = {
|
||||
instanceIdOf,
|
||||
listJobs: () =>
|
||||
new Promise<InstallJobSnapshot[]>((resolve) => {
|
||||
releaseList = () => resolve([])
|
||||
}),
|
||||
execute: async () => {
|
||||
executeCalls += 1
|
||||
return job('started', 'queued')
|
||||
},
|
||||
}
|
||||
const request = {
|
||||
instanceId: 'instance-a',
|
||||
planId: 'plan-a',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct' as const,
|
||||
displayNames,
|
||||
}
|
||||
const first = submitInstanceUpgradeWith(request, lock, dependencies)
|
||||
const second = submitInstanceUpgradeWith(request, lock, dependencies)
|
||||
assert.equal(await second, null)
|
||||
releaseList?.()
|
||||
await first
|
||||
assert.equal(executeCalls, 1)
|
||||
})
|
||||
|
||||
test('active preflight attaches without a second execution', async () => {
|
||||
const calls: unknown[][] = []
|
||||
const submitted = await submitInstanceUpgradeWith(
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'plan-a',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct',
|
||||
displayNames,
|
||||
},
|
||||
{ value: false },
|
||||
submissionDependencies(calls, [job('existing', 'running')]),
|
||||
)
|
||||
assert.equal(submitted?.attached, true)
|
||||
assert.equal(submitted?.job.job_id, 'existing')
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('copy execution result attaches by source identity despite different target', async () => {
|
||||
const submitted = await submitInstanceUpgradeWith(
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'copy-plan',
|
||||
createFullBackup: false,
|
||||
sharedUpgradeMode: 'copy_and_upgrade',
|
||||
displayNames,
|
||||
},
|
||||
{ value: false },
|
||||
{
|
||||
instanceIdOf,
|
||||
listJobs: async () => [],
|
||||
execute: async () =>
|
||||
job('copy-job', 'queued', {
|
||||
instanceId: 'copy-target',
|
||||
sourceInstanceId: 'instance-a',
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert.equal(submitted?.job.job_id, 'copy-job')
|
||||
assert.equal(submitted?.attached, false)
|
||||
})
|
||||
|
||||
test('submission failure releases lock', async () => {
|
||||
const lock = { value: false }
|
||||
await assert.rejects(
|
||||
submitInstanceUpgradeWith(
|
||||
{
|
||||
instanceId: 'instance-a',
|
||||
planId: 'plan-a',
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: 'direct',
|
||||
displayNames,
|
||||
},
|
||||
lock,
|
||||
{
|
||||
instanceIdOf,
|
||||
listJobs: async () => [],
|
||||
execute: async () => {
|
||||
throw new Error('stale plan')
|
||||
},
|
||||
},
|
||||
),
|
||||
/stale plan/,
|
||||
)
|
||||
assert.equal(lock.value, false)
|
||||
})
|
||||
55
apps/app-frontend/src/pages/instance/upgrade/install-job.ts
Normal file
55
apps/app-frontend/src/pages/instance/upgrade/install-job.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
install_job_get,
|
||||
install_job_list,
|
||||
installJobInstanceId,
|
||||
type InstallJobSnapshot,
|
||||
} from '@/helpers/install'
|
||||
import { execute_instance_upgrade } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
isInstanceUpgradeJobWith,
|
||||
selectRecoverableUpgradeJobWith,
|
||||
submitInstanceUpgradeWith,
|
||||
type UpgradeJobSelectionContext,
|
||||
type UpgradeSubmissionLock,
|
||||
type UpgradeSubmissionRequest,
|
||||
type UpgradeSubmissionResult,
|
||||
} from './install-job-core'
|
||||
|
||||
export { isRecoverableUpgradeStatus } from './install-job-core'
|
||||
|
||||
export function isInstanceUpgradeJob(job: InstallJobSnapshot, instanceId: string): boolean {
|
||||
return isInstanceUpgradeJobWith(job, instanceId, installJobInstanceId)
|
||||
}
|
||||
|
||||
export function selectRecoverableUpgradeJob(
|
||||
jobs: InstallJobSnapshot[],
|
||||
instanceId: string,
|
||||
context: UpgradeJobSelectionContext = {},
|
||||
): InstallJobSnapshot | null {
|
||||
return selectRecoverableUpgradeJobWith(jobs, instanceId, context, installJobInstanceId)
|
||||
}
|
||||
|
||||
export async function recoverInstanceUpgradeJob(
|
||||
instanceId: string,
|
||||
context: UpgradeJobSelectionContext = {},
|
||||
): Promise<InstallJobSnapshot | null> {
|
||||
if (context.knownJobId) {
|
||||
const known = await install_job_get(context.knownJobId).catch(() => null)
|
||||
if (known && isInstanceUpgradeJob(known, instanceId)) return known
|
||||
}
|
||||
|
||||
const jobs = await install_job_list(true)
|
||||
return selectRecoverableUpgradeJob(jobs, instanceId, context)
|
||||
}
|
||||
|
||||
export function submitInstanceUpgrade(
|
||||
request: UpgradeSubmissionRequest,
|
||||
lock: UpgradeSubmissionLock,
|
||||
): Promise<UpgradeSubmissionResult | null> {
|
||||
return submitInstanceUpgradeWith(request, lock, {
|
||||
listJobs: install_job_list,
|
||||
execute: execute_instance_upgrade,
|
||||
instanceIdOf: installJobInstanceId,
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isCurrentUpgradeSelectPlanning } from './planning-navigation.ts'
|
||||
|
||||
test('planner continuation navigates only while same Select request remains current', () => {
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(false, 1, 1, 'InstanceUpgrade', 'a', 'a'), true)
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(false, 1, 1, 'InstanceContent', 'a', 'a'), false)
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(false, 1, 2, 'InstanceUpgrade', 'a', 'a'), false)
|
||||
assert.equal(isCurrentUpgradeSelectPlanning(true, 1, 1, 'InstanceUpgrade', 'a', 'a'), false)
|
||||
})
|
||||
|
||||
test('pending planner completion does not navigate after route changes', async () => {
|
||||
let resolvePlanner!: () => void
|
||||
const planner = new Promise<void>((resolve) => {
|
||||
resolvePlanner = resolve
|
||||
})
|
||||
let routeName = 'InstanceUpgrade'
|
||||
let navigations = 0
|
||||
const continuation = planner.then(() => {
|
||||
if (isCurrentUpgradeSelectPlanning(false, 1, 1, routeName, 'a', 'a')) navigations += 1
|
||||
})
|
||||
|
||||
routeName = 'InstanceContent'
|
||||
resolvePlanner()
|
||||
await continuation
|
||||
|
||||
assert.equal(navigations, 0)
|
||||
})
|
||||
|
||||
test('pending planner completion navigates once while Select remains current', async () => {
|
||||
let resolvePlanner!: () => void
|
||||
const planner = new Promise<void>((resolve) => {
|
||||
resolvePlanner = resolve
|
||||
})
|
||||
let navigations = 0
|
||||
const continuation = planner.then(() => {
|
||||
if (isCurrentUpgradeSelectPlanning(false, 1, 1, 'InstanceUpgrade', 'a', 'a')) {
|
||||
navigations += 1
|
||||
}
|
||||
})
|
||||
|
||||
resolvePlanner()
|
||||
await continuation
|
||||
|
||||
assert.equal(navigations, 1)
|
||||
})
|
||||
@ -0,0 +1,15 @@
|
||||
export function isCurrentUpgradeSelectPlanning(
|
||||
disposed: boolean,
|
||||
generation: number,
|
||||
currentGeneration: number,
|
||||
routeName: unknown,
|
||||
routeInstanceId: unknown,
|
||||
instanceId: string,
|
||||
): boolean {
|
||||
return (
|
||||
!disposed &&
|
||||
generation === currentGeneration &&
|
||||
routeName === 'InstanceUpgrade' &&
|
||||
routeInstanceId === instanceId
|
||||
)
|
||||
}
|
||||
78
apps/app-frontend/src/pages/instance/upgrade/result.test.ts
Normal file
78
apps/app-frontend/src/pages/instance/upgrade/result.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
|
||||
import type { InstanceUpgradeResult, InstanceUpgradeSolution } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
isSuccessfulUpgradeJob,
|
||||
summarizeUpgradeResult,
|
||||
upgradeResultLocation,
|
||||
upgradeResultMode,
|
||||
} from './result.ts'
|
||||
|
||||
function result(source = 'source', target = 'target'): InstanceUpgradeResult {
|
||||
return {
|
||||
planId: 'plan',
|
||||
sourceInstanceId: source,
|
||||
targetInstanceId: target,
|
||||
backupInstanceId: null,
|
||||
solution: { kind: 'custom', selections: [], dependencyChanges: [], warnings: [] },
|
||||
compatibilityWarnings: [],
|
||||
externalChanges: [],
|
||||
skippedDueToExternalConflict: [],
|
||||
}
|
||||
}
|
||||
|
||||
function job(
|
||||
status: InstallJobStatus,
|
||||
upgradeResult: InstanceUpgradeResult | null = result(),
|
||||
kind: InstallJobSnapshot['kind'] = 'upgrade_unmanaged_instance',
|
||||
): InstallJobSnapshot {
|
||||
return {
|
||||
job_id: 'job/a',
|
||||
instance_id: upgradeResult?.targetInstanceId ?? 'source',
|
||||
kind,
|
||||
status,
|
||||
upgrade_result: upgradeResult,
|
||||
} as InstallJobSnapshot
|
||||
}
|
||||
|
||||
test('successful upgrade result identifies copy and direct modes', () => {
|
||||
const copyJob = job('succeeded', result('source/a', 'target/b'))
|
||||
assert.equal(isSuccessfulUpgradeJob(copyJob), true)
|
||||
assert.equal(upgradeResultMode(copyJob.upgrade_result!), 'copy_and_upgrade')
|
||||
assert.equal(upgradeResultMode(result('same', 'same')), 'direct')
|
||||
})
|
||||
|
||||
test('successful result links to persisted standalone source-instance page', () => {
|
||||
assert.deepEqual(upgradeResultLocation(job('succeeded', result('source/a', 'target/b'))), {
|
||||
path: '/instance/source%2Fa/upgrade/result',
|
||||
query: { job: 'job/a' },
|
||||
})
|
||||
})
|
||||
|
||||
test('result summary follows executed selection actions and dependency kinds', () => {
|
||||
const solution = {
|
||||
selections: [
|
||||
...Array.from({ length: 3 }, () => ({ action: 'upgrade' })),
|
||||
...Array.from({ length: 2 }, () => ({ action: 'keep' })),
|
||||
{ action: 'disable' },
|
||||
],
|
||||
dependencyChanges: [
|
||||
...Array.from({ length: 2 }, () => ({ kind: 'add' })),
|
||||
...Array.from({ length: 3 }, () => ({ kind: 'upgrade' })),
|
||||
...Array.from({ length: 4 }, () => ({ kind: 'remove' })),
|
||||
...Array.from({ length: 5 }, () => ({ kind: 'keep' })),
|
||||
],
|
||||
} as InstanceUpgradeSolution
|
||||
|
||||
assert.deepEqual(summarizeUpgradeResult(solution), {
|
||||
updated: 3,
|
||||
kept: 2,
|
||||
disabled: 1,
|
||||
dependencyAdded: 2,
|
||||
dependencyUpdated: 3,
|
||||
dependencyRemoved: 4,
|
||||
})
|
||||
})
|
||||
47
apps/app-frontend/src/pages/instance/upgrade/result.ts
Normal file
47
apps/app-frontend/src/pages/instance/upgrade/result.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { InstallJobSnapshot } from '@/helpers/install'
|
||||
import type { InstanceUpgradeResult, InstanceUpgradeSolution } from '@/helpers/instance-upgrade'
|
||||
|
||||
export type UpgradeResultMode = 'direct' | 'copy_and_upgrade'
|
||||
|
||||
export interface UpgradeResultSummary {
|
||||
updated: number
|
||||
kept: number
|
||||
disabled: number
|
||||
dependencyAdded: number
|
||||
dependencyUpdated: number
|
||||
dependencyRemoved: number
|
||||
}
|
||||
|
||||
export function isSuccessfulUpgradeJob(job: InstallJobSnapshot): boolean {
|
||||
return (
|
||||
job.kind === 'upgrade_unmanaged_instance' &&
|
||||
job.status === 'succeeded' &&
|
||||
job.upgrade_result != null
|
||||
)
|
||||
}
|
||||
|
||||
export function upgradeResultMode(result: InstanceUpgradeResult): UpgradeResultMode {
|
||||
return result.sourceInstanceId === result.targetInstanceId ? 'direct' : 'copy_and_upgrade'
|
||||
}
|
||||
|
||||
export function summarizeUpgradeResult(solution: InstanceUpgradeSolution): UpgradeResultSummary {
|
||||
return {
|
||||
updated: solution.selections.filter((selection) => selection.action === 'upgrade').length,
|
||||
kept: solution.selections.filter((selection) => selection.action === 'keep').length,
|
||||
disabled: solution.selections.filter((selection) => selection.action === 'disable').length,
|
||||
dependencyAdded: solution.dependencyChanges.filter((change) => change.kind === 'add').length,
|
||||
dependencyUpdated: solution.dependencyChanges.filter((change) => change.kind === 'upgrade')
|
||||
.length,
|
||||
dependencyRemoved: solution.dependencyChanges.filter((change) => change.kind === 'remove')
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeResultLocation(job: InstallJobSnapshot) {
|
||||
if (!isSuccessfulUpgradeJob(job))
|
||||
return { path: '/downloads', query: { job: job.job_id } } as const
|
||||
return {
|
||||
path: `/instance/${encodeURIComponent(job.upgrade_result!.sourceInstanceId)}/upgrade/result`,
|
||||
query: { job: job.job_id },
|
||||
} as const
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstanceUpgradeSolution } from '@/helpers/instance-upgrade'
|
||||
|
||||
import {
|
||||
filterUpgradeDetailItems,
|
||||
paginateUpgradeDetailItems,
|
||||
UPGRADE_RESULT_PAGE_SIZE,
|
||||
upgradeDetailItems,
|
||||
upgradeDetailProjectIdentities,
|
||||
upgradeDetailReleaseIdentities,
|
||||
} from './upgrade-result-presentation.ts'
|
||||
|
||||
function largeSolution(): InstanceUpgradeSolution {
|
||||
return {
|
||||
kind: 'custom',
|
||||
warnings: [],
|
||||
selections: Array.from({ length: 500 }, (_, index) => ({
|
||||
contentId: `example-${index}`,
|
||||
provider: 'modrinth',
|
||||
projectId: `project-${index}`,
|
||||
currentReleaseId: `old-${index}`,
|
||||
targetReleaseId: `new-${index}`,
|
||||
action: index % 3 === 0 ? 'keep' : index % 3 === 1 ? 'disable' : 'upgrade',
|
||||
enabled: index % 3 !== 1,
|
||||
})),
|
||||
dependencyChanges: [],
|
||||
}
|
||||
}
|
||||
|
||||
test('500-item result paginates to 25 real visible rows per page', () => {
|
||||
const all = upgradeDetailItems(largeSolution())
|
||||
const first = paginateUpgradeDetailItems(all, 1)
|
||||
const second = paginateUpgradeDetailItems(all, 2)
|
||||
assert.equal(UPGRADE_RESULT_PAGE_SIZE, 25)
|
||||
assert.equal(first.items.length, 25)
|
||||
assert.deepEqual(
|
||||
first.items.map((item) => item.contentId),
|
||||
Array.from({ length: 25 }, (_, index) => `example-${index}`),
|
||||
)
|
||||
assert.deepEqual(
|
||||
second.items.map((item) => item.contentId),
|
||||
Array.from({ length: 25 }, (_, index) => `example-${index + 25}`),
|
||||
)
|
||||
})
|
||||
|
||||
test('search and status filters happen before pagination', () => {
|
||||
const all = upgradeDetailItems(largeSolution())
|
||||
const match = filterUpgradeDetailItems(all, 'all', 'example-487')
|
||||
assert.deepEqual(
|
||||
match.map((item) => item.contentId),
|
||||
['example-487'],
|
||||
)
|
||||
const updated = filterUpgradeDetailItems(all, 'updated', '')
|
||||
assert.ok(updated.every((item) => item.action === 'upgrade'))
|
||||
assert.equal(
|
||||
paginateUpgradeDetailItems(updated, 99).page <=
|
||||
paginateUpgradeDetailItems(updated, 99).pageCount,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('metadata scope contains only identities from the current visible page', () => {
|
||||
const visible = paginateUpgradeDetailItems(upgradeDetailItems(largeSolution()), 1).items
|
||||
assert.equal(upgradeDetailProjectIdentities(visible).length, 25)
|
||||
assert.equal(upgradeDetailReleaseIdentities(visible).length, 50)
|
||||
})
|
||||
|
||||
test('component resets page on filter/search and lazily mounts paginated rows', () => {
|
||||
const source = readFileSync(new URL('./UpgradeResultCollections.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /watch\(\[search, filter\],[\s\S]*?page\.value = 1[\s\S]*?\)/)
|
||||
assert.match(source, /v-if="detailsOpen"/)
|
||||
assert.match(source, /v-for="item in visibleRows"/)
|
||||
assert.doesNotMatch(source, /v-for="item in allItems"/)
|
||||
assert.match(source, /upgradeDetailProjectIdentities\(pageData\.value\.items\)/)
|
||||
})
|
||||
@ -0,0 +1,139 @@
|
||||
import type {
|
||||
ContentProvider,
|
||||
InstanceUpgradeDependencyChangeKind,
|
||||
InstanceUpgradeSolution,
|
||||
} from '@/helpers/instance-upgrade'
|
||||
import type {
|
||||
UpgradeProjectIdentity,
|
||||
UpgradeReleaseIdentity,
|
||||
} from '@/helpers/upgrade-version-metadata'
|
||||
|
||||
export const UPGRADE_RESULT_PAGE_SIZE = 25
|
||||
|
||||
export type UpgradeDetailFilter = 'all' | 'updated' | 'kept' | 'disabled' | 'dependencies'
|
||||
|
||||
export interface UpgradeDetailItem {
|
||||
key: string
|
||||
kind: 'selection' | 'dependency'
|
||||
contentId: string | null
|
||||
provider: ContentProvider | null
|
||||
projectId: string | null
|
||||
currentReleaseId: string | null
|
||||
targetReleaseId: string | null
|
||||
action: 'upgrade' | 'keep' | 'disable' | InstanceUpgradeDependencyChangeKind
|
||||
}
|
||||
|
||||
export interface UpgradeDetailPage {
|
||||
items: UpgradeDetailItem[]
|
||||
page: number
|
||||
pageCount: number
|
||||
start: number
|
||||
end: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function upgradeDetailItems(solution: InstanceUpgradeSolution): UpgradeDetailItem[] {
|
||||
return [
|
||||
...solution.selections.map((selection) => ({
|
||||
key: `selection:${selection.contentId}`,
|
||||
kind: 'selection' as const,
|
||||
contentId: selection.contentId,
|
||||
provider: selection.provider,
|
||||
projectId: selection.projectId,
|
||||
currentReleaseId: selection.currentReleaseId,
|
||||
targetReleaseId: selection.targetReleaseId,
|
||||
action: selection.action,
|
||||
})),
|
||||
...solution.dependencyChanges.map((change, index) => ({
|
||||
key: `dependency:${change.provider}:${change.projectId}:${change.existingContentId ?? index}`,
|
||||
kind: 'dependency' as const,
|
||||
contentId: change.existingContentId,
|
||||
provider: change.provider,
|
||||
projectId: change.projectId,
|
||||
currentReleaseId: change.currentReleaseId,
|
||||
targetReleaseId: change.targetReleaseId,
|
||||
action: change.kind,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export function filterUpgradeDetailItems(
|
||||
items: UpgradeDetailItem[],
|
||||
filter: UpgradeDetailFilter,
|
||||
query: string,
|
||||
searchFields: (item: UpgradeDetailItem) => Array<string | null | undefined> = defaultSearchFields,
|
||||
): UpgradeDetailItem[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
return items.filter((item) => {
|
||||
if (filter === 'dependencies' && item.kind !== 'dependency') return false
|
||||
if (filter === 'updated' && (item.kind !== 'selection' || item.action !== 'upgrade'))
|
||||
return false
|
||||
if (filter === 'kept' && (item.kind !== 'selection' || item.action !== 'keep')) return false
|
||||
if (filter === 'disabled' && (item.kind !== 'selection' || item.action !== 'disable'))
|
||||
return false
|
||||
if (!normalizedQuery) return true
|
||||
return searchFields(item).some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
}
|
||||
|
||||
export function paginateUpgradeDetailItems(
|
||||
items: UpgradeDetailItem[],
|
||||
requestedPage: number,
|
||||
pageSize = UPGRADE_RESULT_PAGE_SIZE,
|
||||
): UpgradeDetailPage {
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
|
||||
const page = Math.min(Math.max(1, requestedPage), pageCount)
|
||||
const startIndex = (page - 1) * pageSize
|
||||
return {
|
||||
items: items.slice(startIndex, startIndex + pageSize),
|
||||
page,
|
||||
pageCount,
|
||||
start: items.length ? startIndex + 1 : 0,
|
||||
end: Math.min(startIndex + pageSize, items.length),
|
||||
total: items.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeDetailProjectIdentities(
|
||||
items: UpgradeDetailItem[],
|
||||
): UpgradeProjectIdentity[] {
|
||||
const identities = new Map<string, UpgradeProjectIdentity>()
|
||||
for (const item of items) {
|
||||
if (item.provider !== 'modrinth' && item.provider !== 'curseforge') continue
|
||||
if (!item.projectId) continue
|
||||
identities.set(`${item.provider}:${item.projectId}`, {
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
})
|
||||
}
|
||||
return [...identities.values()]
|
||||
}
|
||||
|
||||
export function upgradeDetailReleaseIdentities(
|
||||
items: UpgradeDetailItem[],
|
||||
): UpgradeReleaseIdentity[] {
|
||||
const identities = new Map<string, UpgradeReleaseIdentity>()
|
||||
for (const item of items) {
|
||||
if (item.provider !== 'modrinth' && item.provider !== 'curseforge') continue
|
||||
if (!item.projectId) continue
|
||||
for (const releaseId of [item.currentReleaseId, item.targetReleaseId]) {
|
||||
if (!releaseId) continue
|
||||
identities.set(`${item.provider}:${item.projectId}:${releaseId}`, {
|
||||
provider: item.provider,
|
||||
projectId: item.projectId,
|
||||
releaseId,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...identities.values()]
|
||||
}
|
||||
|
||||
function defaultSearchFields(item: UpgradeDetailItem) {
|
||||
return [
|
||||
item.contentId,
|
||||
item.provider,
|
||||
item.projectId,
|
||||
item.currentReleaseId,
|
||||
item.targetReleaseId,
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import {
|
||||
clearUpgradeFlow,
|
||||
consumeUpgradeFlow,
|
||||
parkUpgradeFlow,
|
||||
peekUpgradeFlow,
|
||||
restoreUpgradeFlow,
|
||||
upgradeProjectPath,
|
||||
} from '../../../helpers/upgrade-return-state.ts'
|
||||
|
||||
test('upgrade return snapshot is one-shot and instance-scoped', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: '/instance/instance-a/upgrade/compatibility',
|
||||
targetEnvironment: null,
|
||||
plan: null,
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
}
|
||||
parkUpgradeFlow(snapshot)
|
||||
assert.equal(consumeUpgradeFlow('instance-b', snapshot.returnFullPath), null)
|
||||
assert.deepEqual(consumeUpgradeFlow('instance-a', snapshot.returnFullPath), snapshot)
|
||||
assert.equal(consumeUpgradeFlow('instance-a', snapshot.returnFullPath), null)
|
||||
})
|
||||
|
||||
for (const route of ['compatibility', 'customize', 'confirm']) {
|
||||
test(`${route} return hydrates the parked plan before consuming it`, () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: `/instance/instance-a/upgrade/${route}`,
|
||||
targetEnvironment: { gameVersion: '26.1.2' },
|
||||
plan: { id: 'same-plan' },
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
} as never
|
||||
parkUpgradeFlow(snapshot)
|
||||
let hydratedPlanId: string | undefined
|
||||
const restored = restoreUpgradeFlow('instance-a', snapshot.returnFullPath, (value) => {
|
||||
hydratedPlanId = value.plan?.id
|
||||
})
|
||||
assert.equal(hydratedPlanId, 'same-plan')
|
||||
assert.equal(restored?.plan?.id, 'same-plan')
|
||||
assert.equal(peekUpgradeFlow('instance-a'), null)
|
||||
})
|
||||
}
|
||||
|
||||
test('confirm project return restores plan and confirm choices without replanning', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: '/instance/instance-a/upgrade/confirm',
|
||||
targetEnvironment: { gameVersion: '26.1.2' },
|
||||
plan: {
|
||||
id: 'same-plan',
|
||||
selectedSolution: { kind: 'custom' },
|
||||
customConstraints: [{ contentId: 'root', versionId: 'fixed' }],
|
||||
},
|
||||
createFullBackup: false,
|
||||
directFullBackupPreference: false,
|
||||
sharedUpgradeMode: 'direct',
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
} as never
|
||||
parkUpgradeFlow(snapshot)
|
||||
let restoredSnapshot: typeof snapshot | null = null
|
||||
restoreUpgradeFlow('instance-a', snapshot.returnFullPath, (value) => {
|
||||
restoredSnapshot = value as typeof snapshot
|
||||
})
|
||||
|
||||
assert.equal(restoredSnapshot?.plan.id, 'same-plan')
|
||||
assert.deepEqual(restoredSnapshot?.targetEnvironment, snapshot.targetEnvironment)
|
||||
assert.deepEqual(restoredSnapshot?.plan.selectedSolution, snapshot.plan.selectedSolution)
|
||||
assert.deepEqual(restoredSnapshot?.plan.customConstraints, snapshot.plan.customConstraints)
|
||||
assert.equal(restoredSnapshot?.createFullBackup, false)
|
||||
assert.equal(restoredSnapshot?.sharedUpgradeMode, 'direct')
|
||||
})
|
||||
|
||||
test('failed hydration leaves the parked snapshot available', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = {
|
||||
instanceId: 'instance-a',
|
||||
returnFullPath: '/instance/instance-a/upgrade/compatibility',
|
||||
targetEnvironment: null,
|
||||
plan: null,
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
}
|
||||
parkUpgradeFlow(snapshot)
|
||||
assert.throws(() =>
|
||||
restoreUpgradeFlow('instance-a', snapshot.returnFullPath, () => {
|
||||
throw new Error('hydrate failed')
|
||||
}),
|
||||
)
|
||||
assert.deepEqual(peekUpgradeFlow('instance-a'), snapshot)
|
||||
})
|
||||
|
||||
test('confirm project title routes match trusted provider routes only', () => {
|
||||
assert.equal(upgradeProjectPath('modrinth', 'P7dR8mSH'), '/project/P7dR8mSH')
|
||||
assert.equal(upgradeProjectPath('curseforge', '123'), '/project/curseforge/123')
|
||||
assert.equal(upgradeProjectPath('local', 'pack'), null)
|
||||
assert.equal(upgradeProjectPath(null, 'unidentified'), null)
|
||||
})
|
||||
|
||||
test('upgrade return snapshot detaches reactive flow DTOs', () => {
|
||||
clearUpgradeFlow()
|
||||
const snapshot = reactive({
|
||||
instanceId: 'reactive-instance',
|
||||
returnFullPath: '/instance/reactive-instance/upgrade/compatibility',
|
||||
targetEnvironment: null,
|
||||
plan: null,
|
||||
createFullBackup: true,
|
||||
sharedUpgradeMode: null,
|
||||
activeJobId: null,
|
||||
result: null,
|
||||
})
|
||||
parkUpgradeFlow(snapshot)
|
||||
snapshot.createFullBackup = false
|
||||
assert.equal(peekUpgradeFlow('reactive-instance')?.createFullBackup, true)
|
||||
assert.equal(consumeUpgradeFlow('wrong-instance', snapshot.returnFullPath), null)
|
||||
})
|
||||
@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
import { shouldExpandUpgradeWarningsByDefault } from '../../../helpers/post-upgrade-notice.ts'
|
||||
import {
|
||||
filterUpgradeWarnings,
|
||||
paginateUpgradeWarnings,
|
||||
summarizeUpgradeWarnings,
|
||||
UPGRADE_WARNING_PAGE_SIZE,
|
||||
upgradeResultWarningRows,
|
||||
upgradeWarningDisplayName,
|
||||
upgradeWarningMessageId,
|
||||
} from './upgrade-warning.ts'
|
||||
|
||||
const base = {
|
||||
planId: 'plan',
|
||||
sourceInstanceId: 'source',
|
||||
targetInstanceId: 'target',
|
||||
backupInstanceId: null,
|
||||
solution: { kind: 'custom', selections: [], dependencyChanges: [], warnings: [] },
|
||||
externalChanges: [],
|
||||
skippedDueToExternalConflict: [],
|
||||
} as InstanceUpgradeResult
|
||||
|
||||
test('structured warning maps by stable code', () => {
|
||||
const rows = upgradeResultWarningRows({
|
||||
...base,
|
||||
compatibilityWarnings: [],
|
||||
compatibilityWarningDetails: [
|
||||
{
|
||||
code: 'keep_incompatible',
|
||||
relativePath: 'mods/a.jar',
|
||||
contentId: 'a',
|
||||
provider: 'modrinth',
|
||||
projectId: 'project',
|
||||
conflictingProjectId: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(upgradeWarningMessageId(rows[0].code!), 'instance.upgrade.warning.keep-incompatible')
|
||||
const zhCn = JSON.parse(
|
||||
readFileSync(new URL('../../../locales/zh-CN/index.json', import.meta.url), 'utf8'),
|
||||
) as Record<string, { message: string }>
|
||||
const localized = zhCn[upgradeWarningMessageId(rows[0].code!)]?.message
|
||||
assert.equal(localized, '{path} 已原样保留,可能与升级后的实例不兼容。')
|
||||
assert.doesNotMatch(localized, /will be preserved/i)
|
||||
})
|
||||
|
||||
test('legacy persisted warning falls back to raw message', () => {
|
||||
const rows = upgradeResultWarningRows({
|
||||
...base,
|
||||
compatibilityWarnings: [
|
||||
{
|
||||
code: 'unidentified',
|
||||
message: 'Legacy backend text',
|
||||
contentId: null,
|
||||
provider: null,
|
||||
projectId: null,
|
||||
conflictingProjectId: null,
|
||||
dependencyRequirements: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(rows[0].legacyMessage, 'Legacy backend text')
|
||||
})
|
||||
|
||||
test('300 structured warnings stay summarized with path as secondary data', () => {
|
||||
const rows = upgradeResultWarningRows({
|
||||
...base,
|
||||
compatibilityWarnings: [],
|
||||
compatibilityWarningDetails: Array.from({ length: 300 }, (_, index) => ({
|
||||
code:
|
||||
index < 200 ? 'unidentified' : index < 275 ? 'no_compatible_release' : 'prerelease_only',
|
||||
relativePath: `resourcepacks/example-${index}.zip`,
|
||||
contentId: `content-${index}`,
|
||||
provider: null,
|
||||
projectId: null,
|
||||
conflictingProjectId: null,
|
||||
})),
|
||||
})
|
||||
assert.deepEqual(summarizeUpgradeWarnings(rows), { local: 200, kept: 75, fallback: 25 })
|
||||
assert.equal(upgradeWarningDisplayName(rows[0]), 'example-0.zip')
|
||||
assert.equal(shouldExpandUpgradeWarningsByDefault(rows.length), false)
|
||||
const zhCn = JSON.parse(
|
||||
readFileSync(new URL('../../../locales/zh-CN/index.json', import.meta.url), 'utf8'),
|
||||
) as Record<string, { message: string }>
|
||||
assert.equal(
|
||||
zhCn['instance.upgrade.result.warning-unidentified-headline']?.message,
|
||||
'此内容在升级时被原样保留',
|
||||
)
|
||||
|
||||
const page1 = paginateUpgradeWarnings(rows, 1)
|
||||
const page2 = paginateUpgradeWarnings(rows, 2)
|
||||
const lastPage = paginateUpgradeWarnings(rows, 30)
|
||||
assert.equal(UPGRADE_WARNING_PAGE_SIZE, 10)
|
||||
assert.equal(page1.items.length, 10)
|
||||
assert.deepEqual(
|
||||
page2.items.map((row) => row.contentId),
|
||||
Array.from({ length: 10 }, (_, index) => `content-${index + 10}`),
|
||||
)
|
||||
assert.deepEqual(
|
||||
lastPage.items.map((row) => row.contentId),
|
||||
Array.from({ length: 10 }, (_, index) => `content-${index + 290}`),
|
||||
)
|
||||
const remainderPage = paginateUpgradeWarnings(rows.slice(0, 293), 30)
|
||||
assert.deepEqual(
|
||||
remainderPage.items.map((row) => row.contentId),
|
||||
['content-290', 'content-291', 'content-292'],
|
||||
)
|
||||
const searched = filterUpgradeWarnings(rows, 'all', 'example-287')
|
||||
assert.deepEqual(
|
||||
searched.map((row) => row.contentId),
|
||||
['content-287'],
|
||||
)
|
||||
assert.equal(paginateUpgradeWarnings(searched, 1).items.length, 1)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'all', '').length, 300)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'local', '').length, 200)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'kept', '').length, 75)
|
||||
assert.equal(filterUpgradeWarnings(rows, 'fallback', '').length, 25)
|
||||
|
||||
const source = readFileSync(new URL('./UpgradeResultCollections.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, /v-if="warningsOpen"/)
|
||||
assert.match(source, /v-for="warning in warningPage\.items"/)
|
||||
assert.doesNotMatch(source, /v-for="warning in warnings"/)
|
||||
assert.match(source, /technicalDetails/)
|
||||
assert.match(source, /warningHeadline\(warning\)/)
|
||||
assert.match(
|
||||
source,
|
||||
/watch\(\[warningSearch, warningFilter\],[\s\S]*?warningPageNumber\.value = 1/,
|
||||
)
|
||||
|
||||
const buttonSlotStart = source.indexOf('<template #button="{ open }">')
|
||||
const buttonSlotEnd = source.indexOf('</template>', buttonSlotStart)
|
||||
const summaryPosition = source.indexOf('warningSummary.local', buttonSlotStart)
|
||||
assert.ok(
|
||||
buttonSlotStart >= 0 && summaryPosition > buttonSlotStart && summaryPosition < buttonSlotEnd,
|
||||
)
|
||||
assert.match(source, /class="block w-full"/)
|
||||
assert.match(source, /button-class="[^"]*w-full[^"]*focus-visible:ring-4/)
|
||||
|
||||
const accordionSource = readFileSync(
|
||||
new URL('../../../../../../packages/ui/src/components/base/Accordion.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const accordionButtonSlot = accordionSource.lastIndexOf('<slot name="button"')
|
||||
const accordionButtonStart = accordionSource.lastIndexOf('<button', accordionButtonSlot)
|
||||
const accordionButtonEnd = accordionSource.indexOf('</button>', accordionButtonSlot)
|
||||
assert.ok(
|
||||
accordionButtonStart >= 0 &&
|
||||
accordionButtonSlot > accordionButtonStart &&
|
||||
accordionButtonSlot < accordionButtonEnd,
|
||||
)
|
||||
})
|
||||
123
apps/app-frontend/src/pages/instance/upgrade/upgrade-warning.ts
Normal file
123
apps/app-frontend/src/pages/instance/upgrade/upgrade-warning.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import type { InstanceUpgradeIssueCode, InstanceUpgradeResult } from '@/helpers/instance-upgrade'
|
||||
|
||||
export interface UpgradeWarningRow {
|
||||
key: string
|
||||
code: InstanceUpgradeIssueCode | null
|
||||
contentId: string | null
|
||||
relativePath: string | null
|
||||
provider: string | null
|
||||
projectId: string | null
|
||||
legacyMessage: string | null
|
||||
}
|
||||
|
||||
export type UpgradeWarningCategory = 'local' | 'kept' | 'fallback'
|
||||
export type UpgradeWarningFilter = 'all' | UpgradeWarningCategory
|
||||
|
||||
export const UPGRADE_WARNING_PAGE_SIZE = 10
|
||||
|
||||
export interface UpgradeWarningSummary {
|
||||
local: number
|
||||
kept: number
|
||||
fallback: number
|
||||
}
|
||||
|
||||
export interface UpgradeWarningPage {
|
||||
items: UpgradeWarningRow[]
|
||||
page: number
|
||||
pageCount: number
|
||||
start: number
|
||||
end: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function upgradeWarningMessageId(code: InstanceUpgradeIssueCode): string {
|
||||
return `instance.upgrade.warning.${code.replaceAll('_', '-')}`
|
||||
}
|
||||
|
||||
export function upgradeResultWarningRows(result: InstanceUpgradeResult): UpgradeWarningRow[] {
|
||||
if (result.compatibilityWarningDetails !== undefined) {
|
||||
return result.compatibilityWarningDetails.map((warning, index) => ({
|
||||
key: `${warning.code}:${warning.contentId ?? warning.relativePath ?? index}`,
|
||||
code: warning.code,
|
||||
contentId: warning.contentId,
|
||||
relativePath: warning.relativePath,
|
||||
provider: warning.provider,
|
||||
projectId: warning.projectId,
|
||||
legacyMessage: null,
|
||||
}))
|
||||
}
|
||||
return result.compatibilityWarnings.map((warning, index) => ({
|
||||
key: `${warning.code}:${warning.contentId ?? index}`,
|
||||
code: null,
|
||||
contentId: warning.contentId,
|
||||
relativePath: null,
|
||||
provider: warning.provider,
|
||||
projectId: warning.projectId,
|
||||
legacyMessage: warning.message || warning.code,
|
||||
}))
|
||||
}
|
||||
|
||||
export function upgradeWarningCategory(row: UpgradeWarningRow): UpgradeWarningCategory {
|
||||
if (row.code === 'unidentified' || row.code === 'unsupported_content_type') return 'local'
|
||||
if (row.code === 'keep_incompatible' || row.code === 'no_compatible_release') return 'kept'
|
||||
return 'fallback'
|
||||
}
|
||||
|
||||
export function summarizeUpgradeWarnings(rows: UpgradeWarningRow[]): UpgradeWarningSummary {
|
||||
const summary: UpgradeWarningSummary = { local: 0, kept: 0, fallback: 0 }
|
||||
for (const row of rows) summary[upgradeWarningCategory(row)] += 1
|
||||
return summary
|
||||
}
|
||||
|
||||
export function filterUpgradeWarnings(
|
||||
rows: UpgradeWarningRow[],
|
||||
filter: UpgradeWarningFilter,
|
||||
query: string,
|
||||
searchFields: (
|
||||
row: UpgradeWarningRow,
|
||||
) => Array<string | null | undefined> = defaultWarningSearchFields,
|
||||
): UpgradeWarningRow[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
return rows.filter((row) => {
|
||||
if (filter !== 'all' && upgradeWarningCategory(row) !== filter) return false
|
||||
if (!normalizedQuery) return true
|
||||
return searchFields(row).some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
}
|
||||
|
||||
export function paginateUpgradeWarnings(
|
||||
rows: UpgradeWarningRow[],
|
||||
requestedPage: number,
|
||||
pageSize = UPGRADE_WARNING_PAGE_SIZE,
|
||||
): UpgradeWarningPage {
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||
const page = Math.min(Math.max(1, requestedPage), pageCount)
|
||||
const startIndex = (page - 1) * pageSize
|
||||
return {
|
||||
items: rows.slice(startIndex, startIndex + pageSize),
|
||||
page,
|
||||
pageCount,
|
||||
start: rows.length ? startIndex + 1 : 0,
|
||||
end: Math.min(startIndex + pageSize, rows.length),
|
||||
total: rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeWarningDisplayName(row: UpgradeWarningRow): string | null {
|
||||
const path = row.relativePath?.replaceAll('\\', '/')
|
||||
const filename = path?.split('/').filter(Boolean).at(-1)
|
||||
return filename ?? row.projectId ?? row.contentId
|
||||
}
|
||||
|
||||
export function upgradeWarningContentKind(row: UpgradeWarningRow): string {
|
||||
const path = row.relativePath?.replaceAll('\\', '/').toLocaleLowerCase()
|
||||
if (path?.startsWith('resourcepacks/')) return 'resourcepack'
|
||||
if (path?.startsWith('shaderpacks/')) return 'shaderpack'
|
||||
if (path?.startsWith('datapacks/')) return 'datapack'
|
||||
if (path?.startsWith('mods/')) return 'mod'
|
||||
return 'content'
|
||||
}
|
||||
|
||||
function defaultWarningSearchFields(row: UpgradeWarningRow) {
|
||||
return [row.contentId, row.relativePath, row.code, row.provider, row.projectId, row.legacyMessage]
|
||||
}
|
||||
Reference in New Issue
Block a user