feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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')
})

View 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,
}
}

View File

@ -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,
})
})

View 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)
})

View 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
)
}

View File

@ -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'],
)
})

View File

@ -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)
}

View 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)
})

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

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

View 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)
})

View 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,
})
}

View File

@ -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)
})

View File

@ -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
)
}

View 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,
})
})

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

View File

@ -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\)/)
})

View File

@ -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,
]
}

View File

@ -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)
})

View File

@ -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,
)
})

View 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]
}