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,125 @@
<script setup lang="ts">
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject } from 'vue'
import { useRouter } from 'vue-router'
const { formatMessage } = useVIntl()
const router = useRouter()
const showModal = inject<
(options?: {
skipSetupType?: boolean
initialMode?: 'custom' | 'import'
onBack?: () => void
}) => void
>('showCreationModalWithOptions')
const messages = defineMessages({
title: {
id: 'create.title',
defaultMessage: 'Create Instance',
},
subtitle: {
id: 'create.subtitle',
defaultMessage: 'Start a new adventure or bring your existing worlds',
},
newTitle: {
id: 'create.new.title',
defaultMessage: 'Start Fresh',
},
newDescription: {
id: 'create.new.description',
defaultMessage: 'Create a new Minecraft instance from scratch.',
},
importTitle: {
id: 'create.import.title',
defaultMessage: 'Import Existing',
},
importDescription: {
id: 'create.import.description',
defaultMessage: 'Import instances from other launchers or install a modpack.',
},
back: {
id: 'create.back',
defaultMessage: 'Back to Library',
},
pclHmclHint: {
id: 'create.pcl-hmcl-hint',
defaultMessage: 'Using PCL / HMCL?',
},
addMinecraftFolder: {
id: 'create.add-minecraft-folder',
defaultMessage: 'Add .minecraft folder',
},
})
const navigateBack = () => router.push('/library')
function handleStartFresh() {
showModal?.({
skipSetupType: true,
initialMode: 'custom',
onBack: () => router.push('/create'),
})
}
function handleImportExisting() {
showModal?.({
skipSetupType: true,
initialMode: 'import',
onBack: () => router.push('/create'),
})
}
</script>
<template>
<div class="flex h-full w-full flex-col items-center justify-center p-6">
<div class="flex w-full max-w-2xl flex-col gap-6">
<div class="flex flex-col gap-2">
<h1 class="m-0 text-2xl font-bold text-contrast">
{{ formatMessage(messages.title) }}
</h1>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.subtitle) }}
</p>
</div>
<div data-onboarding-id="creation-methods" class="flex flex-col gap-4 sm:flex-row">
<BigOptionButton
data-onboarding-id="creation-method-custom"
:icon="SparklesIcon"
:title="formatMessage(messages.newTitle)"
:description="formatMessage(messages.newDescription)"
no-icon-box
@click="handleStartFresh"
/>
<BigOptionButton
data-onboarding-id="creation-method-import"
:icon="FolderOpenIcon"
:title="formatMessage(messages.importTitle)"
:description="formatMessage(messages.importDescription)"
no-icon-box
@click="handleImportExisting"
/>
</div>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.pclHmclHint) }}
{{ ' ' }}
<RouterLink
to="/settings#storage-backups"
class="text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
>
{{ formatMessage(messages.addMinecraftFolder) }}
</RouterLink>
</p>
<Button transparent class="self-start" @click="navigateBack">
<LeftArrowIcon class="size-4" stroke-width="2" />
{{ formatMessage(messages.back) }}
</Button>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,934 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BookmarkFilledIcon,
BookmarkIcon,
CheckIcon,
ChevronDownIcon,
GenericListIcon,
GridIcon,
ListIcon,
PlusIcon,
SearchIcon,
SpinnerIcon,
} from '@modrinth/assets'
import {
type BrowseInstallContext,
BrowseInstallHeader,
ButtonStyled,
commonMessages,
defineMessages,
EmptyState,
getLatestMatchingInstallVersion,
getTargetInstallPreferences,
injectNotificationManager,
LoadingIndicator,
NavTabs,
Pagination,
PopoutMenu,
ProjectCard,
ProjectCardList,
SelectedProjectsFloatingBar,
StyledInput,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useContentFavorites } from '@/composables/useContentFavorites'
import { createBrowseProjectTabs, getBrowseProjectTabOptions } from '@/helpers/browse-project-tabs'
import {
completeBrowseReturnNavigation,
consumeBrowseReturnSnapshot,
isBrowseReturnSourcePath,
saveBrowseReturnSnapshot,
} from '@/helpers/browse-return-state.ts'
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
import {
type ContentFavorite,
contentFavoriteKey,
type FavoriteContentType,
type FavoriteProvider,
} from '@/helpers/content-favorites'
import {
type CurseForgeProject,
getCurseForgeFiles,
getCurseForgeImageUrl,
getCurseForgeProjects,
} from '@/helpers/curseforge'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import {
getLastBrowseContentDisplayMode,
setLastBrowseContentDisplayMode,
} from '@/helpers/settings'
import type { GameInstance } from '@/helpers/types'
import { injectContentSelection, makeContentSelectionKey } from '@/providers/content-selection'
import { useBreadcrumbs } from '@/store/breadcrumbs'
type FavoriteFilter = 'all' | FavoriteContentType
type FavoriteDisplayMode = 'list' | 'compact' | 'grid'
type FavoriteProject = {
favorite: ContentFavorite
provider: FavoriteProvider
projectId: string
title: string
description: string
slug?: string
iconUrl?: string
downloads?: number
categories: string[]
dateCreated?: string
dateModified?: string
banner?: string
color?: string | number
environment?: {
clientSide: Labrinth.Projects.v2.Environment
serverSide: Labrinth.Projects.v2.Environment
}
unavailable: boolean
}
type FavoritesReturnState = {
projects: FavoriteProject[]
}
const PAGE_SIZE = 20
const curseForgeLoaderTypes: Record<string, number> = {
forge: 1,
fabric: 4,
quilt: 5,
neoforge: 6,
}
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const contentSelection = injectContentSelection()
const contentFavorites = useContentFavorites()
const instanceSelector = ref<InstanceType<typeof BrowseInstanceSelector>>()
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'FavoritesInstallHeader',
)
const projects = shallowRef<FavoriteProject[]>([])
const loadingProjects = ref(false)
const installingKeys = ref(new Set<string>())
const displayMode = ref<FavoriteDisplayMode>(getLastBrowseContentDisplayMode())
let projectRequestId = 0
const browseReturnSnapshot = consumeBrowseReturnSnapshot<FavoritesReturnState>(route.fullPath)
if (browseReturnSnapshot) projects.value = browseReturnSnapshot.state.projects
const messages = defineMessages({
title: {
id: 'app.content-favorites.title',
defaultMessage: 'Favorites',
},
search: {
id: 'app.content-favorites.search',
defaultMessage: 'Search favorites',
},
allContentTypes: {
id: 'app.content-favorites.type.all',
defaultMessage: 'All content types',
},
mods: {
id: 'app.browse.project-type.mods',
defaultMessage: 'Mods',
},
resourcepacks: {
id: 'app.browse.project-type.resourcepacks',
defaultMessage: 'Resource Packs',
},
datapacks: {
id: 'app.browse.project-type.datapacks',
defaultMessage: 'Data Packs',
},
shaders: {
id: 'app.browse.project-type.shaders',
defaultMessage: 'Shaders',
},
modpacks: {
id: 'app.browse.project-type.modpacks',
defaultMessage: 'Modpacks',
},
maps: {
id: 'app.browse.project-type.maps',
defaultMessage: 'Maps',
},
servers: {
id: 'app.browse.project-type.servers',
defaultMessage: 'Servers',
},
chooseInstance: {
id: 'app.browse.choose-instance',
defaultMessage: 'Choose instance',
},
backToInstance: {
id: 'app.browse.back-to-instance',
defaultMessage: 'Back to instance',
},
installSelected: {
id: 'app.browse.install-selected',
defaultMessage: 'Install {count} content',
},
preparingSelected: {
id: 'app.browse.preparing-selected',
defaultMessage: 'Preparing {completed}/{total}',
},
selected: {
id: 'app.browse.selected',
defaultMessage: 'Selected',
},
noCompatibleVersion: {
id: 'app.browse.no-compatible-version',
defaultMessage: 'No compatible version was found for the selected instance.',
},
remove: {
id: 'app.content-favorites.remove',
defaultMessage: 'Remove from favorites',
},
view: {
id: 'app.browse.display-mode.switch',
defaultMessage: 'Switch view',
},
listView: {
id: 'app.browse.display-mode.list',
defaultMessage: 'List',
},
compactView: {
id: 'app.browse.display-mode.compact-list',
defaultMessage: 'Compact list',
},
gridView: {
id: 'app.browse.display-mode.grid',
defaultMessage: 'Grid',
},
recentlySaved: {
id: 'app.content-favorites.sort.recently-saved',
defaultMessage: 'Recently saved',
},
emptyTitle: {
id: 'app.content-favorites.empty-title',
defaultMessage: 'No favorites yet',
},
emptyDescription: {
id: 'app.content-favorites.empty-description',
defaultMessage: 'Bookmark mods, resource packs, data packs, and shaders to install them later.',
},
noMatchesTitle: {
id: 'app.content-favorites.no-matches-title',
defaultMessage: 'No matching favorites',
},
noMatchesDescription: {
id: 'app.content-favorites.no-matches-description',
defaultMessage: 'Try a different search or content type.',
},
unavailableTitle: {
id: 'app.content-favorites.unavailable-title',
defaultMessage: '{provider} project {projectId}',
},
unavailableDescription: {
id: 'app.content-favorites.unavailable-description',
defaultMessage: 'This project is currently unavailable. You can remove it from favorites.',
},
})
function queryValue(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function parseFavoriteFilter(value: unknown): FavoriteFilter {
return value === 'mod' || value === 'resourcepack' || value === 'datapack' || value === 'shader'
? value
: 'all'
}
function parsePage(value: unknown): number {
const page = Number.parseInt(queryValue(value), 10)
return Number.isFinite(page) && page > 0 ? page : 1
}
function updateQuery(values: Record<string, string | undefined>) {
void router.replace({
query: {
...route.query,
...values,
},
})
}
const search = computed({
get: () => queryValue(route.query.q),
set: (value: string) => updateQuery({ q: value || undefined, page: undefined }),
})
const filter = computed<FavoriteFilter>({
get: () => parseFavoriteFilter(route.query.kind),
set: (value) => updateQuery({ kind: value === 'all' ? undefined : value, page: undefined }),
})
const currentPage = computed({
get: () => parsePage(route.query.page),
set: (page: number) => updateQuery({ page: page > 1 ? String(page) : undefined }),
})
const favoriteTypeOptions = computed(() => [
{ id: 'all' as const, label: formatMessage(messages.allContentTypes) },
{ id: 'mod' as const, label: formatMessage(messages.mods) },
{ id: 'resourcepack' as const, label: formatMessage(messages.resourcepacks) },
{ id: 'datapack' as const, label: formatMessage(messages.datapacks) },
{ id: 'shader' as const, label: formatMessage(messages.shaders) },
])
const currentFavoriteTypeLabel = computed(
() => favoriteTypeOptions.value.find((option) => option.id === filter.value)?.label,
)
const displayModeOptions = computed(() => [
{ id: 'list' as const, label: formatMessage(messages.listView), icon: ListIcon },
{ id: 'compact' as const, label: formatMessage(messages.compactView), icon: GenericListIcon },
{ id: 'grid' as const, label: formatMessage(messages.gridView), icon: GridIcon },
])
const currentDisplayMode = computed(() =>
displayModeOptions.value.find((option) => option.id === displayMode.value),
)
const projectTabs = computed(() => {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(route.query)) {
if (key === 'kind' || key === 'q' || key === 'page') continue
if (Array.isArray(value)) {
for (const entry of value) {
if (entry != null) query.append(key, entry)
}
} else if (value) {
query.set(key, value)
}
}
const suffix = query.size ? `?${query.toString()}` : ''
return createBrowseProjectTabs(
{
modpacks: formatMessage(messages.modpacks),
mods: formatMessage(messages.mods),
resourcepacks: formatMessage(messages.resourcepacks),
datapacks: formatMessage(messages.datapacks),
maps: formatMessage(messages.maps),
shaders: formatMessage(messages.shaders),
servers: formatMessage(messages.servers),
favorites: formatMessage(messages.title),
},
suffix,
getBrowseProjectTabOptions({
instance: contentSelection.targetInstance.value,
hasInstanceContext: !!route.query.i,
isServerInstance:
contentSelection.targetInstance.value?.link?.type === 'server_project' ||
contentSelection.targetInstance.value?.link?.type === 'server_project_modpack',
}),
)
})
function typeLabel(type: FavoriteContentType): string {
return formatMessage(
{
mod: messages.mods,
resourcepack: messages.resourcepacks,
datapack: messages.datapacks,
shader: messages.shaders,
}[type],
)
}
function toUnavailable(favorite: ContentFavorite): FavoriteProject {
const provider =
favorite.provider === 'curseforge'
? 'CurseForge'
: favorite.provider === 'mcarchive'
? 'MCArchive'
: 'Modrinth'
return {
favorite,
provider: favorite.provider,
projectId: favorite.project_id,
title: formatMessage(messages.unavailableTitle, {
provider,
projectId: favorite.project_id,
}),
description: formatMessage(messages.unavailableDescription),
categories: [typeLabel(favorite.content_type)],
unavailable: true,
}
}
function toModrinthFavorite(
project: Labrinth.Projects.v2.Project,
favorite: ContentFavorite,
): FavoriteProject {
return {
favorite,
provider: 'modrinth',
projectId: project.id,
title: project.title,
description: project.description,
slug: project.slug,
iconUrl: project.icon_url,
downloads: project.downloads,
categories: [typeLabel(favorite.content_type), ...project.categories],
dateCreated: project.published,
dateModified: project.updated,
banner: project.gallery?.find((image) => image.featured)?.url,
color: project.color,
environment: {
clientSide: project.client_side,
serverSide: project.server_side,
},
unavailable: false,
}
}
function toCurseForgeFavorite(
project: CurseForgeProject,
favorite: ContentFavorite,
): FavoriteProject {
return {
favorite,
provider: 'curseforge',
projectId: project.id.toString(),
title: project.name,
description: project.summary,
slug: project.slug,
iconUrl: getCurseForgeImageUrl(project.logo?.thumbnailUrl),
downloads: project.downloadCount,
categories: [
typeLabel(favorite.content_type),
...project.categories.map((category) => category.slug),
],
dateCreated: project.dateCreated,
dateModified: project.dateModified,
banner: getCurseForgeImageUrl(project.screenshots[0]?.thumbnailUrl, 960),
unavailable: false,
}
}
async function loadModrinthFavorites(projectIds: string[]) {
try {
return (await get_project_many(projectIds, 'bypass')) as Labrinth.Projects.v2.Project[]
} catch (error) {
handleError(error)
return await get_project_many(projectIds, 'cache_only').catch((cacheError) => {
handleError(cacheError)
return [] as Labrinth.Projects.v2.Project[]
})
}
}
async function loadCurseForgeFavorites(projectIds: number[]) {
try {
return await getCurseForgeProjects(projectIds, 'bypass')
} catch (error) {
handleError(error)
return await getCurseForgeProjects(projectIds, 'cache_only').catch((cacheError) => {
handleError(cacheError)
return [] as CurseForgeProject[]
})
}
}
async function refreshProjects() {
const requestId = ++projectRequestId
loadingProjects.value = true
try {
await contentFavorites.load(true)
const favorites = [...contentFavorites.favorites.value]
if (favorites.length === 0) {
if (requestId === projectRequestId) projects.value = []
return
}
const modrinthIds = favorites
.filter((favorite) => favorite.provider === 'modrinth')
.map((favorite) => favorite.project_id)
const curseForgeIds = favorites
.filter((favorite) => favorite.provider === 'curseforge')
.map((favorite) => Number(favorite.project_id))
.filter(Number.isSafeInteger)
const [modrinthResult, curseForgeResult] = await Promise.all([
modrinthIds.length
? loadModrinthFavorites(modrinthIds)
: Promise.resolve([] as Labrinth.Projects.v2.Project[]),
curseForgeIds.length
? loadCurseForgeFavorites(curseForgeIds)
: Promise.resolve([] as CurseForgeProject[]),
])
if (requestId !== projectRequestId) return
const modrinthById = new Map(modrinthResult.map((project) => [project.id, project]))
const curseForgeById = new Map(
curseForgeResult.map((project) => [project.id.toString(), project]),
)
projects.value = favorites.map((favorite) => {
if (favorite.provider === 'modrinth') {
const project = modrinthById.get(favorite.project_id)
return project ? toModrinthFavorite(project, favorite) : toUnavailable(favorite)
}
if (favorite.provider === 'curseforge') {
const project = curseForgeById.get(favorite.project_id)
return project ? toCurseForgeFavorite(project, favorite) : toUnavailable(favorite)
}
return toUnavailable(favorite)
})
} catch (error) {
handleError(error)
} finally {
if (requestId === projectRequestId) loadingProjects.value = false
}
}
const activeFavoriteKeys = computed(
() =>
new Set(
contentFavorites.favorites.value.map((favorite) =>
contentFavoriteKey(favorite.provider, favorite.project_id),
),
),
)
const availableProjects = computed(() =>
projects.value
.filter((project) =>
activeFavoriteKeys.value.has(contentFavoriteKey(project.provider, project.projectId)),
)
.sort((left, right) => right.favorite.saved_at - left.favorite.saved_at),
)
const filteredProjects = computed(() => {
const normalizedSearch = search.value.trim().toLocaleLowerCase()
return availableProjects.value.filter((project) => {
if (filter.value !== 'all' && project.favorite.content_type !== filter.value) return false
if (!normalizedSearch) return true
return [project.title, project.description, project.provider, project.projectId]
.join('\n')
.toLocaleLowerCase()
.includes(normalizedSearch)
})
})
const pageCount = computed(() => Math.max(1, Math.ceil(filteredProjects.value.length / PAGE_SIZE)))
const pagedProjects = computed(() => {
const page = Math.min(currentPage.value, pageCount.value)
const offset = (page - 1) * PAGE_SIZE
return filteredProjects.value.slice(offset, offset + PAGE_SIZE)
})
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count
})
const installContext = computed<BrowseInstallContext | null>(() => {
const target = contentSelection.targetInstance.value
if (!target) return null
const icon = getDisplayInstanceIcon(target.icon_path, target.loader)
const processing = ['validating', 'reviewing', 'queueing'].includes(contentSelection.state.value)
return {
showInstallHeader: !!route.query.i,
name: target.name,
loader: target.loader,
gameVersion: target.game_version,
iconSrc: icon.url,
iconFrameless: icon.frameless,
backUrl: route.query.i ? `/instance/${encodeURIComponent(target.id)}` : route.fullPath,
backLabel: formatMessage(messages.backToInstance),
heading: formatMessage(commonMessages.installingContentLabel),
selectedProjects: contentSelection.selectedProjects.value,
isInstallingSelected: processing,
installProgress: contentSelection.progress.value,
installButtonLabel: formatMessage(messages.installSelected, {
count: contentSelection.selectedCount.value,
}),
processingLabel: formatMessage(messages.preparingSelected, {
completed: contentSelection.progress.value.completed,
total: contentSelection.progress.value.total,
}),
clearSelected: contentSelection.clear,
installSelected: contentSelection.installSelected,
}
})
function isInstalling(project: FavoriteProject) {
const key = makeContentSelectionKey(project.provider, project.projectId)
return installingKeys.value.has(key) || contentSelection.isInstalling(key)
}
function isSelected(project: FavoriteProject) {
return contentSelection.isSelected(makeContentSelectionKey(project.provider, project.projectId))
}
function installLabel(project: FavoriteProject) {
if (isInstalling(project)) return formatMessage(commonMessages.validatingLabel)
if (isSelected(project)) return formatMessage(messages.selected)
return formatMessage(commonMessages.installButton)
}
function setInstalling(key: string, installing: boolean) {
const next = new Set(installingKeys.value)
if (installing) next.add(key)
else next.delete(key)
installingKeys.value = next
}
function getInstallPreferences(target: GameInstance, type: FavoriteContentType) {
return getTargetInstallPreferences(
{ gameVersion: target.game_version, loader: target.loader },
type,
)
}
async function getModrinthVersions(projectId: string) {
const project = (await get_project(projectId, 'must_revalidate')) as Labrinth.Projects.v2.Project
return (await get_version_many(
project.versions,
'must_revalidate',
)) as Labrinth.Versions.v2.Version[]
}
async function toggleProjectSelection(project: FavoriteProject) {
if (project.unavailable) return
if (!contentSelection.targetInstance.value) {
await contentSelection.refreshInstances(queryValue(route.query.i) || undefined)
instanceSelector.value?.show()
return
}
const target = contentSelection.targetInstance.value
const key = makeContentSelectionKey(project.provider, project.projectId)
if (contentSelection.isSelected(key)) {
contentSelection.remove(key)
return
}
setInstalling(key, true)
try {
const preferences = getInstallPreferences(target, project.favorite.content_type)
const versionId =
project.provider === 'modrinth'
? getLatestMatchingInstallVersion(await getModrinthVersions(project.projectId), preferences)
?.id
: (
await getCurseForgeFiles(Number(project.projectId), {
gameVersion: target.game_version,
modLoaderType:
project.favorite.content_type === 'mod'
? curseForgeLoaderTypes[target.loader]
: undefined,
})
).files
.find((file) => file.isAvailable)
?.id.toString()
if (!versionId) throw new Error(formatMessage(messages.noCompatibleVersion))
await contentSelection.add({
key,
provider: project.provider,
projectId: project.projectId,
providerProjectId: project.projectId,
versionId,
contentType: project.favorite.content_type,
title: project.title,
iconUrl: project.iconUrl,
slug: project.slug,
preferences,
})
} catch (error) {
handleError(error)
} finally {
setInstalling(key, false)
}
}
async function removeFavorite(project: FavoriteProject) {
try {
await contentFavorites.remove(project.provider, project.projectId)
} catch (error) {
handleError(error)
}
}
async function selectTargetInstance(target: GameInstance) {
contentSelection.setTarget(target)
await contentSelection.refreshInstalledIdentities()
await router.replace({ query: { ...route.query, i: target.id } })
}
function setDisplayMode(mode: FavoriteDisplayMode) {
displayMode.value = mode
setLastBrowseContentDisplayMode(mode)
}
function getProjectLink(project: FavoriteProject) {
if (project.unavailable) return undefined
return {
path:
project.provider === 'curseforge'
? `/project/curseforge/${project.projectId}`
: `/project/${project.slug ?? project.projectId}`,
query: { ...route.query, b: route.fullPath },
}
}
onBeforeRouteLeave((to) => {
if (isBrowseReturnSourcePath(to.path)) {
const viewport = document.querySelector<HTMLElement>('.app-viewport')
saveBrowseReturnSnapshot<FavoritesReturnState>({
url: route.fullPath,
scrollTop: viewport?.scrollTop ?? 0,
state: { projects: projects.value },
})
}
breadcrumbs.setContext({
name: formatMessage(messages.title),
link: '/browse/favorites',
query: route.query,
})
})
watch(
() =>
contentFavorites.favorites.value
.map((favorite) => `${favorite.provider}:${favorite.project_id}`)
.join('|'),
() => void refreshProjects(),
)
onMounted(async () => {
breadcrumbs.setName('FavoritesTitle', formatMessage(messages.title))
await contentSelection.refreshInstances(queryValue(route.query.i) || undefined)
if (browseReturnSnapshot) {
void nextTick().then(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => {
document.querySelector<HTMLElement>('.app-viewport')?.scrollTo({
top: browseReturnSnapshot.scrollTop,
})
completeBrowseReturnNavigation(route.fullPath)
resolve()
})
}),
)
}
await refreshProjects()
})
</script>
<template>
<div data-onboarding-id="browse-favorites-content" class="flex flex-col gap-3 p-6">
<div
v-if="installContext?.showInstallHeader"
ref="stickyInstallHeaderRef"
class="browse-install-header sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader :install-context="installContext" />
</div>
<div class="flex min-h-12 items-center justify-between">
<NavTabs :links="projectTabs" />
</div>
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
autocomplete="off"
clearable
:placeholder="formatMessage(messages.search)"
wrapper-class="flex-1"
input-class="h-12"
/>
<ButtonStyled size="standard" type="standard">
<button class="flex min-w-0 items-center gap-2" @click="instanceSelector?.show()">
<InstanceIcon
v-if="contentSelection.targetInstance.value"
class="shrink-0"
size="1.25rem"
:icon-path="contentSelection.targetInstance.value.icon_path"
:instance-id="contentSelection.targetInstance.value.id"
:loader="contentSelection.targetInstance.value.loader"
/>
<PlusIcon v-else class="size-5 shrink-0" />
<span class="max-w-40 truncate font-medium">
{{
contentSelection.targetInstance.value?.name ?? formatMessage(messages.chooseInstance)
}}
</span>
<span
aria-hidden="true"
class="flex size-4 shrink-0 items-center justify-center text-secondary"
>
<ChevronDownIcon class="size-4" />
</span>
</button>
</ButtonStyled>
<PopoutMenu placement="bottom-end">
<ButtonStyled size="standard" type="standard">
<button class="flex items-center gap-2">
<BookmarkIcon class="size-5" />
<span>{{ currentFavoriteTypeLabel }}</span>
</button>
</ButtonStyled>
<template #menu>
<div class="flex w-48 flex-col gap-1 p-1">
<ButtonStyled
v-for="option in favoriteTypeOptions"
:key="option.id"
:type="filter === option.id ? 'filled' : 'transparent'"
>
<button
class="flex w-full !justify-start text-left"
:aria-pressed="filter === option.id"
@click="filter = option.id"
>
{{ option.label }}
</button>
</ButtonStyled>
</div>
</template>
</PopoutMenu>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-secondary">{{
formatMessage(messages.recentlySaved)
}}</span>
<PopoutMenu :tooltip="formatMessage(messages.view)" placement="bottom-end" class="ml-auto">
<ButtonStyled circular>
<button :aria-label="formatMessage(messages.view)">
<component :is="currentDisplayMode?.icon" />
</button>
</ButtonStyled>
<template #menu>
<div class="flex w-44 flex-col gap-1 p-1">
<ButtonStyled
v-for="option in displayModeOptions"
:key="option.id"
:type="displayMode === option.id ? 'filled' : 'transparent'"
>
<button
class="flex w-full items-center gap-2 !justify-start text-left"
:aria-pressed="displayMode === option.id"
@click="setDisplayMode(option.id)"
>
<component :is="option.icon" class="size-4" />
{{ option.label }}
</button>
</ButtonStyled>
</div>
</template>
</PopoutMenu>
<Pagination :page="currentPage" :count="pageCount" @switch-page="currentPage = $event" />
</div>
<section
v-if="loadingProjects && availableProjects.length === 0"
class="flex min-h-64 items-center justify-center"
>
<LoadingIndicator />
</section>
<EmptyState
v-else-if="contentFavorites.loaded.value && contentFavorites.favorites.value.length === 0"
type="empty-inbox"
:heading="formatMessage(messages.emptyTitle)"
:description="formatMessage(messages.emptyDescription)"
/>
<EmptyState
v-else-if="filteredProjects.length === 0"
type="no-search-result"
:heading="formatMessage(messages.noMatchesTitle)"
:description="formatMessage(messages.noMatchesDescription)"
/>
<ProjectCardList v-else :layout="displayMode">
<ProjectCard
v-for="project in pagedProjects"
:key="`${project.provider}:${project.projectId}`"
:layout="displayMode"
:link="getProjectLink(project)"
:title="project.title"
:summary="project.description"
:icon-url="project.iconUrl"
:downloads="project.downloads"
:tags="project.categories"
:date-published="project.dateCreated"
:date-updated="project.dateModified"
:banner="project.banner"
:color="project.color"
:environment="project.environment"
:provider="project.provider"
>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="!project.unavailable"
color="brand"
type="outlined"
:size="displayMode === 'compact' ? 'small' : 'standard'"
>
<button
:disabled="isInstalling(project)"
@click.stop="toggleProjectSelection(project)"
>
<SpinnerIcon v-if="isInstalling(project)" class="animate-spin" />
<CheckIcon v-else-if="isSelected(project)" />
<PlusIcon v-else />
{{ installLabel(project) }}
</button>
</ButtonStyled>
<ButtonStyled
circular
color="brand"
type="transparent"
:size="displayMode === 'compact' ? 'small' : 'standard'"
>
<button
v-tooltip="formatMessage(messages.remove)"
:disabled="contentFavorites.isPending(project.provider, project.projectId)"
:aria-label="formatMessage(messages.remove)"
@click.stop="removeFavorite(project)"
>
<SpinnerIcon
v-if="contentFavorites.isPending(project.provider, project.projectId)"
class="animate-spin"
/>
<BookmarkFilledIcon v-else />
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</ProjectCardList>
<div class="flex justify-end">
<Pagination :page="currentPage" :count="pageCount" @switch-page="currentPage = $event" />
</div>
<SelectedProjectsFloatingBar :install-context="installContext" />
<BrowseInstanceSelector
ref="instanceSelector"
:instances="contentSelection.instances.value"
:selected-instance="contentSelection.targetInstance.value"
:selected-count="contentSelection.selectedCount.value"
:install-current="contentSelection.installSelected"
:clear-current="contentSelection.clear"
@select="selectTargetInstance"
/>
</div>
</template>

View File

@ -0,0 +1,533 @@
<script setup lang="ts">
import {
CheckIcon,
GridIcon,
LayoutTemplateIcon,
MinimizeIcon,
MoveIcon,
PencilIcon,
PlusIcon,
RotateCounterClockwiseIcon,
} from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
injectPageContext,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
createDefaultHomeDashboard,
createHomeDashboardSaveQueue,
type HomeDashboardConfig,
normalizeHomeDashboard,
} from '@/components/home/home-dashboard'
import { getActivePlayerName } from '@/components/home/home-utils'
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
import HomeDashboard from '@/components/home/HomeDashboard.vue'
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
import { instance_listener } from '@/helpers/events'
import { list } from '@/helpers/instance'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
import type { FeatureFlag, HomeLayout } from '@/store/theme'
const { handleError } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const { formatMessage } = useVIntl()
const { offline } = useNetworkStatus()
const themeStore = useTheming()
const pageContext = injectPageContext()
const messages = defineMessages({
home: { id: 'app.home.breadcrumb', defaultMessage: 'Home' },
switchToMinimal: {
id: 'app.home.layout.switch-to-minimal',
defaultMessage: 'Switch to Minimal Home',
},
switchToInformation: {
id: 'app.home.layout.switch-to-information',
defaultMessage: 'Switch to Information Home',
},
homeLayoutToggle: {
id: 'app.home.layout.toggle',
defaultMessage: 'Minimal Home',
},
switchToGridWidgetLayout: {
id: 'app.home.widgets.layout.switch-to-grid',
defaultMessage: 'Switch to grid widget layout',
},
switchToFreeWidgetLayout: {
id: 'app.home.widgets.layout.switch-to-free',
defaultMessage: 'Switch to free widget layout',
},
widgetLayoutToggle: {
id: 'app.home.widgets.layout.toggle',
defaultMessage: 'Widget layout mode',
},
resetWidgets: {
id: 'app.home.widgets.reset-confirm',
defaultMessage: 'Restore the default widget layout?',
},
customizeWidgets: {
id: 'app.home.widgets.customize',
defaultMessage: 'Customize widgets',
},
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
resetWidgetLayout: {
id: 'app.home.widgets.reset',
defaultMessage: 'Restore default widgets',
},
})
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
const instances = ref<GameInstance[]>([])
const playerName = ref<string | null>(null)
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
const dashboardEditing = ref(false)
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
const switchingLayout = ref(false)
const dashboardSaveQueue = createHomeDashboardSaveQueue(
async (config) => {
const settings = await getSettings()
settings.home_widgets = config
await setSettings(settings)
},
(config) => {
dashboardConfig.value = config
},
handleError,
)
const floatingControlsStyle = computed(() => ({
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
}))
const animateSidebarShow = ref(false)
setTimeout(() => {
animateSidebarShow.value = true
}, 200)
async function clearMissingMinimalInstance() {
const selectedId = themeStore.minimalHomeInstanceId
if (!selectedId || instances.value.some((instance) => instance.id === selectedId)) return
themeStore.minimalHomeInstanceId = null
try {
const settings = await getSettings()
if (settings.minimal_home_instance_id === null) return
settings.minimal_home_instance_id = null
await setSettings(settings)
} catch (error) {
handleError(error)
}
}
async function fetchInstances() {
try {
instances.value = await list()
await clearMissingMinimalInstance()
return true
} catch (error) {
handleError(error)
return false
}
}
async function fetchPlayerName() {
const selectedUser = await get_default_user(offline.value).catch(() => undefined)
if (!selectedUser) return
const accounts = await users(offline.value).catch(() => [])
playerName.value = getActivePlayerName(selectedUser, accounts)
}
async function loadDashboardConfig() {
try {
const settings = await getSettings()
const normalized = normalizeHomeDashboard(settings.home_widgets)
if (normalized) {
dashboardConfig.value = normalized
return
}
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
dashboardConfig.value = config
settings.home_widgets = config
await setSettings(settings)
} catch (error) {
dashboardConfig.value = createDefaultHomeDashboard(
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
)
handleError(error)
}
}
function updateDashboardConfig(config: HomeDashboardConfig) {
const previous = dashboardConfig.value ?? config
dashboardConfig.value = config
void dashboardSaveQueue.enqueue(config, previous)
}
function resetDashboardConfig() {
if (!window.confirm(formatMessage(messages.resetWidgets))) return
updateDashboardConfig(createDefaultHomeDashboard())
}
async function selectMinimalInstance(instance: GameInstance) {
try {
const settings = await getSettings()
settings.minimal_home_instance_id = instance.id
await setSettings(settings)
themeStore.minimalHomeInstanceId = instance.id
} catch (error) {
handleError(error)
}
}
function createInstance() {
void router.push('/create')
}
async function toggleHomeLayout() {
if (switchingLayout.value) return
const previousLayout = themeStore.homeLayout
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
const previousEditing = dashboardEditing.value
switchingLayout.value = true
themeStore.homeLayout = nextLayout
if (nextLayout === 'minimal') dashboardEditing.value = false
try {
const settings = await getSettings()
settings.home_layout = nextLayout
await setSettings(settings)
} catch (error) {
themeStore.homeLayout = previousLayout
dashboardEditing.value = previousEditing
handleError(error)
} finally {
switchingLayout.value = false
}
}
function toggleDashboardEditing() {
dashboardEditing.value = !dashboardEditing.value
}
function toggleWidgetLayout() {
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
}
function openWidgetPicker() {
dashboard.value?.openWidgetPicker()
}
const instancesLoaded = await fetchInstances()
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
await loadDashboardConfig()
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
const unlistenInstance = await instance_listener(async () => {
await fetchInstances()
})
onUnmounted(() => {
unlistenInstance()
window.removeEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
})
</script>
<template>
<HomeInstancePickerModal
ref="instancePicker"
:instances="instances"
:selected-instance-id="themeStore.minimalHomeInstanceId"
@select="selectMinimalInstance"
/>
<div class="min-h-full">
<HomeDashboard
v-if="!isMinimal && dashboardConfig"
ref="dashboard"
:config="dashboardConfig"
:instances="instances"
:player-name="playerName"
:editing="dashboardEditing"
@change="updateDashboardConfig"
/>
<HomeMinimal
v-else
:instances="instances"
:player-name="playerName"
:selected-instance-id="themeStore.minimalHomeInstanceId"
@choose="instancePicker?.show()"
@create="createInstance"
/>
</div>
<div class="home-floating-controls" :style="floatingControlsStyle">
<template v-if="!isMinimal">
<button
v-if="dashboardEditing"
v-tooltip="formatMessage(messages.addWidget)"
type="button"
class="home-floating-action"
:aria-label="formatMessage(messages.addWidget)"
@click="openWidgetPicker"
>
<PlusIcon />
</button>
<button
v-if="dashboardEditing"
v-tooltip="formatMessage(messages.resetWidgetLayout)"
type="button"
class="home-floating-action"
:aria-label="formatMessage(messages.resetWidgetLayout)"
@click="resetDashboardConfig"
>
<RotateCounterClockwiseIcon />
</button>
<button
v-tooltip="
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
"
data-onboarding-id="home-widget-customize"
type="button"
class="home-floating-action"
:class="{ 'is-active': dashboardEditing }"
:aria-label="
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
"
:aria-pressed="dashboardEditing"
@click="toggleDashboardEditing"
>
<CheckIcon v-if="dashboardEditing" />
<PencilIcon v-else />
</button>
<button
v-if="dashboardEditing"
v-tooltip="
formatMessage(
isFreeWidgetLayout
? messages.switchToGridWidgetLayout
: messages.switchToFreeWidgetLayout,
)
"
type="button"
role="switch"
class="home-layout-switch home-widget-layout-switch"
:class="{ 'is-free': isFreeWidgetLayout }"
:aria-checked="isFreeWidgetLayout"
:aria-label="formatMessage(messages.widgetLayoutToggle)"
@click="toggleWidgetLayout"
>
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
<GridIcon />
</span>
<span class="home-layout-switch-thumb" aria-hidden="true" />
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
<MoveIcon />
</span>
</button>
<span class="home-floating-divider" aria-hidden="true" />
</template>
<button
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
data-onboarding-id="home-layout-switch"
type="button"
role="switch"
class="home-layout-switch"
:class="{ 'is-minimal': isMinimal }"
:disabled="switchingLayout"
:aria-checked="isMinimal"
:aria-label="formatMessage(messages.homeLayoutToggle)"
@click="toggleHomeLayout"
>
<span class="home-layout-switch-option home-layout-switch-information" aria-hidden="true">
<LayoutTemplateIcon />
</span>
<span class="home-layout-switch-thumb" aria-hidden="true" />
<span class="home-layout-switch-option home-layout-switch-minimal" aria-hidden="true">
<MinimizeIcon />
</span>
</button>
</div>
<Teleport v-if="!isMinimal" to="#sidebar-default-teleport-target">
<div
class="flex min-w-0 flex-col slide-enter-active"
:class="{ 'slide-enter-from': !animateSidebarShow }"
>
<HomePlayInsights />
<HomeDailyChallenge />
<HomeMinecraftNews />
</div>
</Teleport>
</template>
<style scoped>
.home-floating-controls {
position: fixed;
z-index: 40;
display: flex;
height: 2.5rem;
align-items: center;
gap: 0.125rem;
padding: 0.25rem;
box-sizing: border-box;
border: 1px solid var(--color-divider);
border-radius: 9999px;
background: var(--color-raised-bg);
box-shadow:
var(--shadow-button),
0 0.25rem 0.75rem rgb(0 0 0 / 20%);
isolation: isolate;
}
.home-floating-action {
display: flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 9999px;
background: transparent;
color: var(--color-secondary);
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease,
filter 150ms ease,
transform 150ms ease;
}
.home-floating-action:hover {
background: var(--color-button-bg);
color: var(--color-contrast);
}
.home-floating-action:active {
transform: scale(0.96);
}
.home-floating-action:focus-visible,
.home-layout-switch:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--color-brand-shadow);
}
.home-floating-action.is-active {
background: var(--color-brand);
color: var(--color-accent-contrast);
}
.home-floating-action :deep(svg) {
width: 1rem;
height: 1rem;
}
.home-floating-divider {
width: 1px;
height: 1.25rem;
margin: 0 0.125rem;
background: var(--color-divider);
}
.home-layout-switch {
position: relative;
display: grid;
grid-template-columns: repeat(2, 2rem);
align-items: center;
/* width: 4.25rem; */ /* closes #210 */
height: 2rem;
margin: 0;
padding: 0;
border: 0;
border-radius: 9999px;
background: var(--color-button-bg);
cursor: pointer;
isolation: isolate;
transition:
filter 150ms ease,
transform 150ms ease;
}
.home-layout-switch:hover:not(:disabled) {
filter: brightness(var(--hover-brightness));
}
.home-layout-switch:active:not(:disabled) {
transform: scale(0.97);
}
.home-layout-switch:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.home-layout-switch-thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
z-index: 0;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--color-brand);
transition: transform 180ms ease;
}
.home-layout-switch.is-minimal .home-layout-switch-thumb {
transform: translateX(2rem);
}
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
transform: translateX(2rem);
}
.home-layout-switch-option {
position: relative;
z-index: 1;
display: flex;
width: 2rem;
height: 1.75rem;
align-items: center;
justify-content: center;
color: var(--color-secondary);
transition: color 180ms ease;
}
.home-layout-switch-option :deep(svg) {
width: 1rem;
height: 1rem;
}
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
.home-layout-switch.is-minimal .home-layout-switch-minimal,
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
.home-widget-layout-switch.is-free .home-widget-layout-free {
color: var(--color-accent-contrast);
}
</style>

View File

@ -0,0 +1,334 @@
<script setup lang="ts">
import { ChevronRightIcon, SearchIcon, StarIcon } from '@modrinth/assets'
import {
Card,
defineMessages,
DropdownSelect,
EmptyState,
NewButton,
StyledInput,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import gradientTextToolCover from '@/assets/lab/gradient-text-tool-cover.png'
import modTranslationCover from '@/assets/lab/mod-translation-cover.png'
import recipeGeneratorToolCover from '@/assets/lab/recipe-generator-tool-cover.png'
import schematicPreviewToolCover from '@/assets/lab/schematic-preview-cover.png'
import seedMapToolCover from '@/assets/lab/seed-map-tool-cover.png'
import skinEditorToolCover from '@/assets/lab/skin-editor-tool-cover.png'
import {
getLabCategoryFilter,
getLabFavoriteFilter,
getLabFavoriteToolIds,
type LabCategoryFilter,
type LabFavoriteFilter,
setLabCategoryFilter,
setLabFavoriteFilter,
setLabFavoriteToolIds,
} from '@/helpers/lab-preferences'
import { labTools } from '@/lab/registry'
const { formatMessage } = useVIntl()
const router = useRouter()
const search = ref('')
const category = ref<LabCategoryFilter>(getLabCategoryFilter())
const favoriteFilter = ref<LabFavoriteFilter>(getLabFavoriteFilter())
const favoriteToolIds = ref<string[]>(getLabFavoriteToolIds())
const toolCoverImages: Record<string, string> = {
'gradient-text': gradientTextToolCover,
'recipe-generator': recipeGeneratorToolCover,
'schematic-preview': schematicPreviewToolCover,
'seed-map': seedMapToolCover,
'mod-translation': modTranslationCover,
'skin-editor': skinEditorToolCover,
}
const messages = defineMessages({
title: { id: 'app.lab.title', defaultMessage: 'Lab' },
toolCount: { id: 'app.lab.tool-count', defaultMessage: '{count} tool' },
toolCountPlural: { id: 'app.lab.tool-count-plural', defaultMessage: '{count} tools' },
search: { id: 'app.lab.search', defaultMessage: 'Search tools' },
allTools: { id: 'app.lab.category.all', defaultMessage: 'All tools' },
creation: { id: 'app.lab.category.creation', defaultMessage: 'Creation' },
maintenance: { id: 'app.lab.category.maintenance', defaultMessage: 'Maintenance' },
world: { id: 'app.lab.category.world', defaultMessage: 'World' },
enter: { id: 'app.lab.enter', defaultMessage: 'Enter' },
favoriteFilterAll: { id: 'app.lab.favorite-filter.all', defaultMessage: 'All' },
favoriteFilterFavorite: {
id: 'app.lab.favorite-filter.favorite',
defaultMessage: 'Favorited',
},
favoriteFilterUnfavorite: {
id: 'app.lab.favorite-filter.unfavorite',
defaultMessage: 'Not favorited',
},
favoriteAdd: { id: 'app.lab.favorite.add', defaultMessage: 'Add to favorites' },
favoriteRemove: { id: 'app.lab.favorite.remove', defaultMessage: 'Remove from favorites' },
noResults: { id: 'app.lab.no-results', defaultMessage: 'No tools match your search.' },
noFavorites: {
id: 'app.lab.no-favorites',
defaultMessage: 'You have not favorited any tools yet.',
},
gradientTextTitle: {
id: 'app.lab.gradient-text.title',
defaultMessage: 'Gradient text generator',
},
gradientTextDescription: {
id: 'app.lab.gradient-text.description',
defaultMessage: 'Create Minecraft-ready gradient text without a browser.',
},
recipeGeneratorTitle: {
id: 'app.lab.recipe-generator.title',
defaultMessage: 'Recipe generator',
},
recipeGeneratorDescription: {
id: 'app.lab.recipe-generator.description',
defaultMessage: 'Create Minecraft Java data pack recipes from local item and tag data.',
},
seedMapTitle: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
seedMapDescription: {
id: 'app.lab.seed-map.description',
defaultMessage: 'Explore a Minecraft seed locally with biomes, structures, and saved markers.',
},
schematicPreviewTitle: {
id: 'app.lab.schematic-preview.title',
defaultMessage: 'Schematic workshop',
},
schematicPreviewDescription: {
id: 'app.lab.schematic-preview.description',
defaultMessage: 'Quickly preview and edit your schematics.',
},
modTranslationTitle: {
id: 'app.lab.mod-translation.title',
defaultMessage: 'Mod translation',
},
modTranslationDescription: {
id: 'app.lab.mod-translation.description',
defaultMessage: 'Translate any Minecraft mod JAR into Simplified Chinese.',
},
skinEditorTitle: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
skinEditorDescription: {
id: 'app.lab.skin-editor.description',
defaultMessage: 'Create and edit Minecraft player skins locally.',
},
})
const categoryOptions: LabCategoryFilter[] = ['all', 'creation', 'maintenance', 'world']
const favoriteFilterOptions: LabFavoriteFilter[] = ['all', 'favorite', 'unfavorite']
watch(category, (value) => setLabCategoryFilter(value))
watch(favoriteFilter, (value) => setLabFavoriteFilter(value))
watch(favoriteToolIds, (ids) => setLabFavoriteToolIds(ids))
function isFavorite(toolId: string) {
return favoriteToolIds.value.includes(toolId)
}
function toggleFavorite(toolId: string) {
favoriteToolIds.value = isFavorite(toolId)
? favoriteToolIds.value.filter((id) => id !== toolId)
: [...favoriteToolIds.value, toolId]
}
const visibleTools = computed(() => {
const normalizedSearch = search.value.trim().toLocaleLowerCase()
const favoriteSet = new Set(favoriteToolIds.value)
const filtered = labTools.filter((tool) => {
const matchingCategory = category.value === 'all' || tool.category === category.value
const matchingFavorite =
favoriteFilter.value === 'all' ||
(favoriteFilter.value === 'favorite' && favoriteSet.has(tool.id)) ||
(favoriteFilter.value === 'unfavorite' && !favoriteSet.has(tool.id))
const matchingSearch =
!normalizedSearch ||
[toolTitle(tool.id, tool.title), toolDescription(tool.id, tool.description)]
.join(' ')
.toLocaleLowerCase()
.includes(normalizedSearch)
return matchingCategory && matchingFavorite && matchingSearch
})
const favorited = filtered.filter((tool) => favoriteSet.has(tool.id))
const unfavorited = filtered.filter((tool) => !favoriteSet.has(tool.id))
return [...favorited, ...unfavorited]
})
const emptyHeading = computed(() => {
if (favoriteFilter.value === 'favorite' && favoriteToolIds.value.length === 0) {
return formatMessage(messages.noFavorites)
}
return formatMessage(messages.noResults)
})
function toolTitle(toolId: string, fallback: string) {
if (toolId === 'skin-editor') return formatMessage(messages.skinEditorTitle)
if (toolId === 'gradient-text') return formatMessage(messages.gradientTextTitle)
if (toolId === 'recipe-generator') return formatMessage(messages.recipeGeneratorTitle)
if (toolId === 'seed-map') return formatMessage(messages.seedMapTitle)
if (toolId === 'schematic-preview') return formatMessage(messages.schematicPreviewTitle)
if (toolId === 'mod-translation') return formatMessage(messages.modTranslationTitle)
return fallback
}
function toolDescription(toolId: string, fallback: string) {
if (toolId === 'skin-editor') return formatMessage(messages.skinEditorDescription)
if (toolId === 'gradient-text') return formatMessage(messages.gradientTextDescription)
if (toolId === 'recipe-generator') return formatMessage(messages.recipeGeneratorDescription)
if (toolId === 'seed-map') return formatMessage(messages.seedMapDescription)
if (toolId === 'schematic-preview') return formatMessage(messages.schematicPreviewDescription)
if (toolId === 'mod-translation') return formatMessage(messages.modTranslationDescription)
return fallback
}
function toolOnboardingId(toolId: string) {
return ['gradient-text', 'recipe-generator', 'seed-map', 'schematic-preview'].includes(toolId)
? `lab-${toolId}-card`
: undefined
}
function toolIconClasses(toolId: string) {
if (toolId === 'seed-map') return 'bg-highlight-green text-brand'
return 'bg-brand-highlight text-brand'
}
function categoryLabel(value: LabCategoryFilter) {
if (value === 'all') return formatMessage(messages.allTools)
return formatMessage(messages[value])
}
function favoriteFilterLabel(value: LabFavoriteFilter) {
if (value === 'all') return formatMessage(messages.favoriteFilterAll)
if (value === 'favorite') return formatMessage(messages.favoriteFilterFavorite)
return formatMessage(messages.favoriteFilterUnfavorite)
}
</script>
<template>
<main class="flex w-full flex-col gap-6 p-6">
<header class="flex min-w-0 items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 mt-1 text-sm text-secondary">
{{
formatMessage(labTools.length === 1 ? messages.toolCount : messages.toolCountPlural, {
count: labTools.length,
})
}}
</p>
</div>
</header>
<div class="flex flex-wrap gap-2" aria-label="Lab tool filters">
<StyledInput
v-model="search"
:icon="SearchIcon"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[14rem] flex-1"
/>
<DropdownSelect
v-model="category"
:options="categoryOptions"
:display-name="categoryLabel"
name="Lab category"
class="w-48 max-[576px]:w-full"
/>
<DropdownSelect
v-model="favoriteFilter"
:options="favoriteFilterOptions"
:display-name="favoriteFilterLabel"
name="Lab favorite filter"
class="w-48 max-[576px]:w-full"
/>
</div>
<section
v-if="visibleTools.length"
aria-label="Lab tools"
data-onboarding-id="lab-tools"
class="flex flex-col gap-3"
>
<Card
v-for="tool in visibleTools"
:key="tool.id"
class="!m-0 relative flex items-end gap-4 !p-4 transition-[border-color,filter] duration-200 hover:border-surface-5 hover:brightness-[1.05]"
>
<button
class="absolute right-4 top-4 z-20 flex size-8 items-center justify-center rounded-md text-secondary transition-colors hover:text-brand focus-visible:outline-none"
:aria-label="
isFavorite(tool.id)
? formatMessage(messages.favoriteRemove)
: formatMessage(messages.favoriteAdd)
"
:aria-pressed="isFavorite(tool.id)"
@click="toggleFavorite(tool.id)"
>
<StarIcon
class="size-5"
:class="isFavorite(tool.id) ? 'fill-brand text-brand' : 'text-secondary'"
/>
</button>
<RouterLink
:to="tool.route"
aria-hidden="true"
tabindex="-1"
class="shrink-0 rounded-[var(--radius-lg)] focus-visible:outline-none"
>
<div
class="relative aspect-[2/1] w-56 overflow-hidden rounded-[var(--radius-lg)] bg-surface-2 max-[576px]:w-36"
>
<img
v-if="toolCoverImages[tool.id]"
:src="toolCoverImages[tool.id]"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
<div
v-else
class="flex h-full items-center justify-center"
:class="toolIconClasses(tool.id)"
>
<component :is="tool.icon" class="size-8" />
</div>
</div>
</RouterLink>
<div class="flex min-w-0 flex-1 flex-col">
<RouterLink
:to="tool.route"
:data-onboarding-id="toolOnboardingId(tool.id)"
class="min-w-0 rounded-[var(--radius-lg)] text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
<h2 class="m-0 line-clamp-1 pr-10 text-lg font-bold leading-tight text-contrast">
{{ toolTitle(tool.id, tool.title) }}
</h2>
<p class="m-0 mt-1 line-clamp-2 text-sm leading-5 text-secondary">
{{ toolDescription(tool.id, tool.description) }}
</p>
</RouterLink>
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
<TagItem>{{ categoryLabel(tool.category) }}</TagItem>
<NewButton
type="colored"
color="brand"
size="sm"
class="min-w-20 justify-between px-3"
@click="router.push(tool.route)"
>
<ChevronRightIcon />
{{ formatMessage(messages.enter) }}
</NewButton>
</div>
</div>
</Card>
</section>
<EmptyState v-else type="no-search-result" :heading="emptyHeading" aria-live="polite" />
</main>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,472 @@
<script setup lang="ts">
import { FileArchiveIcon, PlayIcon, SpinnerIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ModTranslationFilePicker from '@/components/lab/mod-translation/ModTranslationFilePicker.vue'
import ModTranslationJobList from '@/components/lab/mod-translation/ModTranslationJobList.vue'
import ModTranslationSettingsPanel from '@/components/lab/mod-translation/ModTranslationSettingsPanel.vue'
import { analyzeMod } from '@/lab/mod-translation/backend'
import { modTranslationMessages as messages } from '@/lab/mod-translation/i18n'
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
import { useModTranslationStore } from '@/store/modTranslation'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const store = useModTranslationStore()
const { inputPath, analysis, providerId, modelId, options, jobs } = storeToRefs(store)
const analyzing = ref(false)
const starting = ref(false)
const analyzeStartedAt = ref<number | null>(null)
const analyzeNow = ref(Date.now())
let analyzeTimer: ReturnType<typeof setInterval> | undefined
watch(analyzing, (value) => {
if (value) {
analyzeStartedAt.value = Date.now()
analyzeNow.value = Date.now()
analyzeTimer = setInterval(() => {
analyzeNow.value = Date.now()
}, 1000)
} else if (analyzeTimer) {
clearInterval(analyzeTimer)
analyzeTimer = undefined
analyzeStartedAt.value = null
}
})
onBeforeUnmount(() => {
if (analyzeTimer) clearInterval(analyzeTimer)
})
const analyzeElapsed = computed(() => {
if (analyzeStartedAt.value === null) return 0
return Math.max(0, Math.floor((analyzeNow.value - analyzeStartedAt.value) / 1000))
})
const canStart = computed(
() =>
!!inputPath.value &&
!!providerId.value &&
!!modelId.value &&
!starting.value &&
!analyzing.value,
)
const startHint = computed(() => {
if (!inputPath.value) return formatMessage(messages.startHint)
if (!providerId.value || !modelId.value) return formatMessage(messages.aiNotConfigured)
return formatMessage(messages.outputPath, { path: defaultOutputPath(inputPath.value) })
})
const summaryStats = computed(() => {
if (!analysis.value) return []
return [
{
label: formatMessage(messages.loader),
value: analysis.value.loader,
},
{
label: formatMessage(messages.languageEntries),
value: String(analysis.value.languageEntries),
},
{
label: formatMessage(messages.languageCharacters),
value: String(analysis.value.languageCharacters),
},
{
label: formatMessage(messages.classCandidates),
value: String(analysis.value.classCandidates.length),
},
{
label: formatMessage(messages.estimatedQuote),
value: formatMessage(messages.estimatedTokens, {
tokens: (analysis.value.quote?.estimatedTokens ?? 0).toLocaleString(),
}),
detail: formatMessage(messages.estimatedTokensDetail, {
calls: analysis.value.quote?.estimatedCalls ?? 0,
input: (analysis.value.quote?.estimatedInputTokens ?? 0).toLocaleString(),
output: (analysis.value.quote?.estimatedOutputTokens ?? 0).toLocaleString(),
}),
full: true,
},
]
})
function defaultOutputPath(path: string): string {
return path.replace(/\.jar$/i, '-zh_cn.jar')
}
async function runAnalyze() {
if (!inputPath.value) return
analyzing.value = true
store.setAnalysis(null)
try {
store.setAnalysis(await analyzeMod(inputPath.value), inputPath.value)
} catch (error) {
handleError(new Error(errorMessage(error)))
} finally {
analyzing.value = false
}
}
async function startTranslation() {
if (!canStart.value) return
starting.value = true
try {
await store.startTranslation()
} catch (error) {
handleError(new Error(errorMessage(error)))
} finally {
starting.value = false
}
}
async function cancelJob(taskId: string) {
try {
await store.cancelJob(taskId)
} catch (error) {
handleError(new Error(errorMessage(error)))
}
}
async function removeJob(taskId: string) {
try {
await store.removeJob(taskId)
} catch (error) {
handleError(new Error(errorMessage(error)))
}
}
function openOutput(job: ModTranslationJob) {
void revealItemInDir(job.outputPath).catch((error) => {
handleError(new Error(String(error)))
})
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message
}
return String(error)
}
onMounted(() => {
void store.init().catch((error) => {
handleError(new Error(errorMessage(error)))
})
})
</script>
<template>
<main class="mod-translation-page flex min-h-0 flex-col gap-4 p-6">
<header class="flex min-w-0 items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 mt-1 text-sm text-secondary">{{ formatMessage(messages.description) }}</p>
</div>
<div
v-if="store.activeJobs.length"
class="background-badge"
:title="formatMessage(messages.backgroundRunning)"
>
<span class="live-dot" />
<span>{{ formatMessage(messages.backgroundRunning) }}</span>
</div>
</header>
<div class="mod-translation-grid">
<!-- 左栏输入与设置 -->
<div class="flex min-w-0 flex-col gap-4">
<section class="panel">
<h2 class="panel-title">{{ formatMessage(messages.inputSection) }}</h2>
<ModTranslationFilePicker v-model:path="inputPath" />
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button :disabled="!inputPath || analyzing" @click="runAnalyze">
<SpinnerIcon v-if="analyzing" class="animate-spin" />
<FileArchiveIcon v-else />
{{ formatMessage(analyzing ? messages.analyzing : messages.analyze) }}
</button>
</ButtonStyled>
<span v-if="!inputPath" class="panel-hint">
{{ formatMessage(messages.selectFile) }}
</span>
</div>
<div v-if="analyzing" class="analyze-status flex items-center gap-2">
<span class="analyze-timer">
{{ formatMessage(messages.analyzingElapsed, { seconds: analyzeElapsed }) }}
</span>
<span class="panel-hint">{{ formatMessage(messages.analyzingHint) }}</span>
</div>
</section>
<section v-if="analysis" class="panel">
<h2 class="panel-title">{{ formatMessage(messages.analysis) }}</h2>
<div class="stats-grid">
<div
v-for="item in summaryStats"
:key="item.label"
class="stat"
:class="{ 'stat-full': item.full }"
>
<span class="stat-label">{{ item.label }}</span>
<span class="stat-value">{{ item.value }}</span>
<span v-if="item.detail" class="stat-detail">{{ item.detail }}</span>
</div>
</div>
<Admonition v-if="analysis.signed" type="warning">
{{ formatMessage(messages.signedMod) }}
</Admonition>
<Admonition v-for="warning in analysis.warnings" :key="warning" type="warning">
{{ warning }}
</Admonition>
<div v-if="analysis.languageSources.length" class="flex flex-col gap-1.5">
<h3 class="m-0 text-sm font-semibold text-contrast">
{{ formatMessage(messages.languageSources) }}
</h3>
<div class="source-list">
<div
v-for="source in analysis.languageSources"
:key="source.sourcePath"
class="source-row"
>
<span class="source-path" :title="source.sourcePath">
{{ source.sourcePath }}
</span>
<span class="source-count">{{ source.required }} / {{ source.entries }}</span>
</div>
</div>
</div>
</section>
<section class="panel">
<h2 class="panel-title">{{ formatMessage(messages.aiSection) }}</h2>
<ModTranslationSettingsPanel
v-model="options"
v-model:provider-id="providerId"
v-model:model-id="modelId"
/>
<div class="flex flex-col gap-1.5">
<ButtonStyled color="brand">
<button class="start-button" @click="startTranslation" :disabled="!canStart">
<PlayIcon />{{ formatMessage(messages.start) }}
</button>
</ButtonStyled>
<span class="panel-hint">{{ startHint }}</span>
</div>
</section>
</div>
<!-- 右栏任务 -->
<div class="flex min-w-0 flex-col">
<section class="panel jobs-panel flex-1">
<h2 class="panel-title">{{ formatMessage(messages.jobsSection) }}</h2>
<ModTranslationJobList
:jobs="jobs"
@cancel="cancelJob"
@remove="removeJob"
@open-output="openOutput"
/>
</section>
</div>
</div>
</main>
</template>
<style scoped>
.mod-translation-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
@media (min-width: 1100px) {
.mod-translation-grid {
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
}
}
.panel {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-2);
padding: 1rem;
}
.panel-title {
margin: 0;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.panel-hint {
font-size: 0.72rem;
color: var(--color-text-secondary);
}
.analyze-timer {
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-brand);
}
.start-button {
display: inline-flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 0.4rem;
white-space: nowrap;
}
.background-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--surface-5);
border-radius: 999px;
background: var(--surface-2);
padding: 0.3rem 0.65rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--color-text-secondary);
white-space: nowrap;
}
.live-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--color-brand);
animation: page-pulse 1.6s ease-out infinite;
}
@keyframes page-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-brand) 50%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 0.5rem transparent;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-3);
padding: 0.5rem 0.65rem;
}
.stat-label {
color: var(--color-text-secondary);
font-size: 0.65rem;
font-weight: 700;
}
.stat-value {
overflow: hidden;
color: var(--color-contrast);
font-size: 0.9rem;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-detail {
color: var(--color-text-secondary);
font-size: 0.62rem;
font-variant-numeric: tabular-nums;
}
.stat-full {
display: flex;
grid-column: 1 / -1;
flex-direction: row;
align-items: baseline;
gap: 0.75rem;
}
.stat-full .stat-label {
flex: 0 0 auto;
}
.stat-full .stat-value {
flex: 0 0 auto;
font-size: 1rem;
}
.stat-full .stat-detail {
margin-left: auto;
}
.source-list {
display: flex;
max-height: 14rem;
flex-direction: column;
gap: 0.3rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.source-row {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-3);
padding: 0.35rem 0.55rem;
}
.source-path {
min-width: 0;
overflow: hidden;
flex: 1;
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-count {
flex: 0 0 auto;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,216 @@
<script setup lang="ts">
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
LoadingIndicator,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { writeFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { createSkinEditorTheme } from '@/components/lab/skin-editor/skin-editor-theme'
const { locale, formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const platformName = ref<string>()
const messages = defineMessages({
title: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
loading: { id: 'app.lab.skin-editor.loading', defaultMessage: 'Loading skin editor' },
loadErrorTitle: {
id: 'app.lab.skin-editor.load-error-title',
defaultMessage: 'Skin editor could not be loaded',
},
loadErrorDescription: {
id: 'app.lab.skin-editor.load-error-description',
defaultMessage: 'The embedded editor did not finish loading. Try again.',
},
retry: { id: 'app.lab.skin-editor.retry', defaultMessage: 'Try again' },
exportSkin: { id: 'app.lab.skin-editor.export-skin', defaultMessage: 'Minecraft skin PNG' },
})
const blockbenchLocale = computed(() => {
const normalized = locale.value.toLowerCase().replace('_', '-')
if (normalized === 'zh-tw' || normalized === 'zh-hk') return 'zh_tw'
if (normalized.startsWith('zh')) return 'zh'
if (normalized === 'pt-br') return 'pt_br'
return normalized.split('-')[0]
})
const editorState = ref<'loading' | 'ready' | 'error'>('loading')
const frameKey = ref(0)
let loadTimeout: number | undefined
const editorUrl = computed(() => {
if (import.meta.env.DEV) {
return `/__blockbench_skin__/index.html?embed=skin&lang=${encodeURIComponent(blockbenchLocale.value)}`
}
if (!platformName.value) return ''
const baseUrl =
platformName.value === 'windows' ? 'http://axolotl-skin.localhost' : 'axolotl-skin://localhost'
return `${baseUrl}/index.html?embed=skin&lang=${encodeURIComponent(blockbenchLocale.value)}`
})
function clearLoadTimeout() {
if (loadTimeout !== undefined) window.clearTimeout(loadTimeout)
loadTimeout = undefined
}
function beginEditorLoad() {
clearLoadTimeout()
editorState.value = 'loading'
loadTimeout = window.setTimeout(() => {
editorState.value = 'error'
}, 15_000)
}
function markEditorReady() {
clearLoadTimeout()
editorState.value = 'ready'
}
function markEditorError() {
clearLoadTimeout()
editorState.value = 'error'
}
async function reloadEditor() {
beginEditorLoad()
if (!editorUrl.value) {
try {
platformName.value = await platform()
} catch (error) {
markEditorError()
handleError(error)
return
}
}
frameKey.value += 1
}
function sendThemeToEditor() {
frame.value?.contentWindow?.postMessage(
{ type: 'axolotl-skin-theme', theme: createSkinEditorTheme() },
'*',
)
}
function handleFrameLoad() {
sendThemeToEditor()
}
async function handleEditorMessage(event: MessageEvent<unknown>) {
if (event.source !== frame.value?.contentWindow) return
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: unknown; name?: unknown; dataUrl?: unknown }
if (message.type === 'axolotl-skin-theme-ready') {
sendThemeToEditor()
markEditorReady()
return
}
if (
message.type !== 'axolotl-skin-export' ||
typeof message.name !== 'string' ||
typeof message.dataUrl !== 'string'
)
return
try {
const path = await save({
defaultPath: message.name,
filters: [{ name: formatMessage(messages.exportSkin), extensions: ['png'] }],
})
if (!path) return
const response = await fetch(message.dataUrl)
if (!response.ok) throw new Error(`Failed to read exported skin: ${response.status}`)
await writeFile(path, new Uint8Array(await response.arrayBuffer()))
} catch (error) {
handleError(error)
}
}
const frame = ref<HTMLIFrameElement>()
let themeObserver: MutationObserver | undefined
watch(
editorUrl,
(url) => {
if (url) beginEditorLoad()
},
{ immediate: true },
)
onMounted(async () => {
window.addEventListener('message', handleEditorMessage)
themeObserver = new MutationObserver(sendThemeToEditor)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style'],
})
if (!import.meta.env.DEV) {
try {
platformName.value = await platform()
} catch (error) {
markEditorError()
handleError(error)
}
}
})
onUnmounted(() => {
clearLoadTimeout()
window.removeEventListener('message', handleEditorMessage)
themeObserver?.disconnect()
})
</script>
<template>
<main class="skin-editor-page relative flex h-full min-h-0 w-full flex-1 bg-surface-1">
<h1 class="sr-only">{{ formatMessage(messages.title) }}</h1>
<div
v-if="editorState === 'loading'"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-surface-1 text-secondary"
role="status"
aria-live="polite"
>
<LoadingIndicator />
<p class="m-0">{{ formatMessage(messages.loading) }}</p>
</div>
<div
v-else-if="editorState === 'error'"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center"
role="alert"
>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.loadErrorTitle) }}
</h2>
<p class="m-0 max-w-md text-secondary">{{ formatMessage(messages.loadErrorDescription) }}</p>
<ButtonStyled color="brand" @click="reloadEditor">
{{ formatMessage(messages.retry) }}
</ButtonStyled>
</div>
<iframe
v-if="editorUrl"
:key="`${frameKey}:${editorUrl}`"
ref="frame"
:title="formatMessage(messages.title)"
:src="editorUrl"
class="h-full min-h-0 w-full flex-1 border-0 transition-opacity duration-150"
:class="editorState === 'ready' ? 'opacity-100' : 'pointer-events-none opacity-0'"
:aria-label="formatMessage(messages.title)"
:aria-hidden="editorState !== 'ready'"
@load="handleFrameLoad"
@error="markEditorError"
/>
</main>
</template>
<style>
.app-viewport:has(.skin-editor-page),
.app-viewport:has(.skin-editor-page) .page-transition-grid,
.app-viewport:has(.skin-editor-page) .page-transition-layer {
height: 100%;
min-height: 0;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { ServerIcon, UsersIcon } from '@modrinth/assets'
import { defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const messages = defineMessages({
title: { id: 'app.multiplayer.title', defaultMessage: 'Multiplayer' },
serversTab: { id: 'app.multiplayer.tab.servers', defaultMessage: 'Servers' },
roomsTab: { id: 'app.multiplayer.tab.rooms', defaultMessage: 'Rooms' },
})
const activeTab = computed(() =>
route.path.startsWith('/multiplayer/rooms') ? 'rooms' : 'servers',
)
// 服务器详情页用固定高度布局:控制台内部滚动,命令输入框始终可见
const isStudioMode = computed(() => route.name === 'MultiplayerServerFileStudio')
const isFixedRender = computed(
() => route.name === 'MultiplayerServerDetail' || route.name === 'MultiplayerServerFileStudio',
)
const tabLinks = computed(() => [
{ label: formatMessage(messages.serversTab), href: '/multiplayer/servers', icon: ServerIcon },
{ label: formatMessage(messages.roomsTab), href: '/multiplayer/rooms', icon: UsersIcon },
])
function handleTabClick(index: number) {
void router.push(tabLinks.value[index]?.href ?? '/multiplayer/servers')
}
</script>
<template>
<div
:class="
isStudioMode
? 'flex h-full min-h-0 w-full flex-col'
: isFixedRender
? 'box-border flex h-full min-h-0 w-full flex-col gap-3 p-6'
: 'box-border flex min-h-full w-full flex-col gap-3 p-6'
"
>
<template v-if="!isStudioMode">
<h1 class="m-0 shrink-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h1>
<NavTabs
mode="local"
:active-index="activeTab === 'rooms' ? 1 : 0"
:links="tabLinks"
@tab-click="handleTabClick"
/>
</template>
<RouterView />
</div>
</template>

View File

@ -0,0 +1,619 @@
<script setup lang="ts">
import { ChevronDownIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
defineMessages,
type MessageDescriptor,
ProgressBar,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
getVisibleSettingsCategories,
getVisibleSettingsGroups,
type SettingsCategory,
settingsPageTitle,
} from '@/components/ui/settings/settings-registry'
import {
filterSettingsSearchDocuments,
normalizeSettingsSearchText,
} from '@/components/ui/settings/settings-search'
import {
getSettingsSearchTargetId,
type SettingsSearchEntry,
} from '@/components/ui/settings/settings-search-index'
import { AxolotlBrandConfig } from '@/config'
import { get, set } from '@/helpers/settings'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress'
import { useTheming } from '@/store/state'
interface SettingsSearchResult {
category: SettingsCategory
entry?: SettingsSearchEntry
label: string
breadcrumb: string
}
const themeStore = useTheming()
const route = useRoute()
const { formatMessage } = useVIntl()
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
const [version, loadedSettings] = await Promise.all([getVersion(), get()])
const osPlatform = getOsPlatform()
const osVersion = getOsVersion()
const settings = ref(loadedSettings)
const devModeCounter = ref(0)
const searchQuery = ref('')
const selectedCategoryId = ref(route.hash.slice(1) || 'interface')
const settingsContentPending = ref(false)
const contentContainer = ref<HTMLElement | null>(null)
const searchHighlightTarget = ref<HTMLElement | null>(null)
const expandedGroups = ref<Record<string, boolean>>({
launcher: true,
game: true,
'data-privacy': true,
support: true,
developer: false,
})
const hasSearchQuery = computed(() => !!normalizeSettingsSearchText(searchQuery.value))
let searchHighlightTimer: ReturnType<typeof window.setTimeout> | undefined
// The settings registry keeps each category lazy. Track only the currently
// selected async component so the shared top loading bar reflects navigation
// without eagerly loading every settings page.
useLoadingBarToken(settingsContentPending)
const messages = defineMessages({
search: {
id: 'app.settings.search.placeholder',
defaultMessage: 'Search settings',
},
clearSearch: {
id: 'app.settings.search.clear',
defaultMessage: 'Clear settings search',
},
noResults: {
id: 'app.settings.search.empty',
defaultMessage: 'No settings match your search.',
},
results: {
id: 'app.settings.search.results',
defaultMessage: 'Search results',
},
downloading: {
id: 'app.settings.downloading',
defaultMessage: 'Downloading v{version}',
},
developerModeEnabled: {
id: 'app.settings.developer-mode-enabled',
defaultMessage: 'Developer mode enabled.',
},
})
const visibleCategories = computed(() => getVisibleSettingsCategories(!!themeStore.devMode))
const visibleGroups = computed(() => getVisibleSettingsGroups(!!themeStore.devMode))
const activeCategory = computed(
() =>
visibleCategories.value.find((category) => category.id === selectedCategoryId.value) ??
visibleCategories.value[0],
)
const searchResults = computed<SettingsSearchResult[]>(() => {
const documents = visibleGroups.value.flatMap((group) =>
group.categories.flatMap((category) => {
const categoryLabel = categoryName(category)
const groupLabel = formatMessage(group.name)
return [
{
item: {
category,
label: categoryLabel,
breadcrumb: groupLabel,
},
text: searchTextVariants([group.name, category.name]),
},
...category.entries.map((entry) => ({
item: {
category,
entry,
label: entryName(entry),
breadcrumb: `${groupLabel} > ${categoryLabel}`,
},
text: [
...searchTextVariants([
group.name,
category.name,
entry.label,
...(entry.keywords ?? []),
]),
...messageSearchTexts(entry.description),
],
})),
]
}),
)
return filterSettingsSearchDocuments(searchQuery.value, documents).map(({ item }) => item)
})
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
watch(visibleCategories, (categories) => {
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = categories[0]?.id ?? 'interface'
}
})
watch(
() => route.hash,
(hash) => {
const categoryId = hash.slice(1)
if (categoryId) selectedCategoryId.value = categoryId
},
)
function selectCategory(categoryId: string) {
selectedCategoryId.value = categoryId
const category = visibleCategories.value.find((item) => item.id === categoryId)
if (category) expandedGroups.value[category.group] = true
contentContainer.value?.scrollTo({ top: 0 })
}
function toggleGroup(groupId: string) {
expandedGroups.value[groupId] = !expandedGroups.value[groupId]
}
async function selectSearchResult(result: SettingsSearchResult) {
selectedCategoryId.value = result.category.id
expandedGroups.value[result.category.group] = true
searchQuery.value = ''
contentContainer.value?.scrollTo({ top: 0 })
if (!result.entry) return
const targetId = getSettingsSearchTargetId(result.entry)
for (let attempt = 0; attempt < 12; attempt++) {
await nextTick()
const target = contentContainer.value?.querySelector<HTMLElement>(`#${targetId}`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
flashSearchTarget(target)
return
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
function toggleDeveloperMode() {
devModeCounter.value++
if (devModeCounter.value <= 5) return
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
}
function categoryName(category: SettingsCategory): string {
return formatMessage(category.name)
}
function entryName(entry: SettingsSearchEntry): string {
return formatMessage(entry.label)
}
function messageSearchTexts(message?: MessageDescriptor): string[] {
// Search metadata is assembled from several registries. Guard the runtime
// boundary so a malformed entry cannot abort the entire settings render.
if (!message || typeof message !== 'object' || typeof message.id !== 'string') return []
const texts = [formatMessage(message), message.defaultMessage].filter(
(text): text is string => !!text,
)
return [...new Set(texts)]
}
function searchTextVariants(messages: Array<MessageDescriptor | undefined>): string[] {
return messages.flatMap(messageSearchTexts)
}
function searchResultKey(result: SettingsSearchResult): string {
return result.entry?.id ?? `category-${result.category.id}`
}
function searchMatchSegments(text: string) {
const query = normalizeSettingsSearchText(searchQuery.value)
if (!query) return [{ text, matched: false }]
const index = text.toLocaleLowerCase().indexOf(query)
if (index < 0) return [{ text, matched: false }]
return [
{ text: text.slice(0, index), matched: false },
{ text: text.slice(index, index + query.length), matched: true },
{ text: text.slice(index + query.length), matched: false },
].filter((segment) => segment.text)
}
function flashSearchTarget(target: HTMLElement) {
const highlightTarget = target.closest<HTMLElement>('.settings-row') ?? target
searchHighlightTarget.value?.classList.remove('settings-search-result-highlight')
if (searchHighlightTimer) window.clearTimeout(searchHighlightTimer)
highlightTarget.classList.add('settings-search-result-highlight')
searchHighlightTarget.value = highlightTarget
searchHighlightTimer = window.setTimeout(() => {
highlightTarget.classList.remove('settings-search-result-highlight')
if (searchHighlightTarget.value === highlightTarget) searchHighlightTarget.value = null
}, 1800)
}
function platformName() {
return osPlatform === 'macos' ? 'macOS' : osPlatform.charAt(0).toUpperCase() + osPlatform.slice(1)
}
const pageTitle: MessageDescriptor = settingsPageTitle
</script>
<template>
<div class="settings-fixed-render h-full min-h-0 pt-6 pl-6 pb-6">
<div class="settings-layout h-full min-h-0">
<aside class="settings-sidebar">
<div class="relative shrink-0">
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-secondary"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="formatMessage(messages.search)"
:aria-label="formatMessage(messages.search)"
class="w-full rounded-lg border border-surface-4 bg-surface-3 py-2 pl-9 pr-9 text-sm text-contrast outline-none transition-colors placeholder:text-secondary focus:border-surface-5"
@keydown.escape="searchQuery = ''"
/>
<button
v-if="searchQuery"
type="button"
class="absolute right-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md border-0 bg-transparent text-secondary transition-colors hover:bg-surface-4 hover:text-contrast"
:aria-label="formatMessage(messages.clearSearch)"
@click="searchQuery = ''"
>
<XIcon class="size-4" />
</button>
</div>
<div
v-if="hasSearchQuery"
class="settings-sidebar-list"
:aria-label="formatMessage(messages.results)"
>
<button
v-for="result in searchResults"
:key="searchResultKey(result)"
type="button"
class="settings-search-result items-center gap-2 p-2 hover:bg-surface-3 hover:text-contrast"
@click="selectSearchResult(result)"
>
<component :is="result.category.icon" class="size-4 shrink-0 text-secondary" />
<span class="settings-search-result-copy">
<span class="settings-search-result-label">
<template v-for="segment in searchMatchSegments(result.label)" :key="segment.text">
<mark v-if="segment.matched" class="settings-search-match">{{
segment.text
}}</mark>
<span v-else>{{ segment.text }}</span>
</template>
</span>
<span class="truncate text-xs text-secondary">{{ result.breadcrumb }}</span>
</span>
</button>
<p v-if="searchResults.length === 0" class="m-0 px-3 py-4 text-sm text-secondary">
{{ formatMessage(messages.noResults) }}
</p>
</div>
<nav v-else class="settings-sidebar-list" :aria-label="formatMessage(pageTitle)">
<section v-for="group in visibleGroups" :key="group.id" class="settings-nav-group">
<button
type="button"
class="settings-group-button hover:bg-surface-3 hover:text-contrast"
:aria-expanded="expandedGroups[group.id]"
@click="toggleGroup(group.id)"
>
<component :is="group.icon" class="size-3.5 shrink-0" />
<span class="truncate">{{ formatMessage(group.name) }}</span>
<ChevronDownIcon
class="ml-auto size-3.5 shrink-0 transition-transform"
:class="expandedGroups[group.id] ? 'rotate-180' : ''"
/>
</button>
<div v-show="expandedGroups[group.id]" class="settings-nav-items">
<button
v-for="category in group.categories"
:key="category.id"
type="button"
:data-onboarding-id="category.onboardingId"
class="settings-category-button hover:bg-surface-3 hover:text-contrast"
:class="{ 'is-active': activeCategory?.id === category.id }"
@click="selectCategory(category.id)"
>
<component :is="category.icon" class="size-4 shrink-0" />
<span class="truncate">{{ categoryName(category) }}</span>
</button>
</div>
</section>
</nav>
<footer class="mt-auto shrink-0 pt-4 text-sm text-secondary">
<div v-if="progress > 0 && progress < 1" class="mb-4">
<p class="m-0 mb-2">
{{ formatMessage(messages.downloading, { version: downloadingVersion }) }}
</p>
<ProgressBar :progress="progress" />
</div>
<p v-if="themeStore.devMode" class="m-0 mb-3 text-brand font-semibold">
{{ formatMessage(messages.developerModeEnabled) }}
</p>
<div class="settings-footer-identity flex items-start gap-3">
<button
type="button"
class="m-0 flex size-9 shrink-0 items-center justify-center rounded-lg border-0 bg-transparent p-0 transition-colors hover:bg-surface-3"
:class="themeStore.devMode ? 'text-brand' : 'text-secondary'"
@click="toggleDeveloperMode"
>
<img class="size-8 object-contain" src="@/assets/axolotl.png" alt="" />
</button>
<div class="settings-footer-version min-w-0">
<p class="m-0 break-words">{{ AxolotlBrandConfig.productName }} {{ version }}</p>
<p class="m-0 truncate">{{ platformName() }} {{ osVersion }}</p>
</div>
</div>
</footer>
</aside>
<section class="settings-content" :aria-label="formatMessage(pageTitle)">
<header class="settings-content-header">
<component :is="activeCategory?.icon" class="size-5 text-secondary" />
<h1 class="m-0 text-xl font-semibold text-contrast">
{{ activeCategory ? categoryName(activeCategory) : formatMessage(pageTitle) }}
</h1>
</header>
<div
ref="contentContainer"
class="settings-content-scroll min-h-0 flex-1"
:class="activeCategory?.flushContent ? 'overflow-hidden' : 'overflow-y-auto'"
>
<div
v-if="activeCategory"
:id="`settings-category-${activeCategory.id}`"
class="min-h-0"
:class="activeCategory.flushContent ? 'h-full' : 'mx-auto max-w-5xl px-6 pb-6'"
tabindex="-1"
>
<Suspense
@pending="settingsContentPending = true"
@resolve="settingsContentPending = false"
>
<component :is="activeCategory.content" :key="activeCategory.id" />
<template #fallback>
<div class="settings-content-fallback" aria-hidden="true" />
</template>
</Suspense>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.settings-layout {
--settings-divider: color-mix(in srgb, var(--surface-4) 55%, transparent);
--settings-card-border: color-mix(in srgb, var(--surface-4) 72%, transparent);
display: grid;
grid-template-columns: minmax(18rem, 20rem) minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.settings-sidebar {
display: flex;
height: 100%;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
overflow: hidden;
padding: var(--gap-lg);
}
.settings-sidebar-list {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--gap-lg);
overflow-y: auto;
}
.settings-nav-group {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--gap-xs);
}
.settings-nav-items {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--gap-xs);
}
.settings-group-button {
display: flex;
width: 100%;
align-items: center;
gap: var(--gap-sm);
min-height: 1.75rem;
padding: 0 var(--gap-sm);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-secondary);
font-size: 0.75rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease;
}
.settings-category-button,
.settings-search-result {
display: flex;
width: 100%;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease;
}
.settings-category-button {
align-items: center;
gap: 0.625rem;
min-height: 2.25rem;
gap: var(--gap-sm);
padding: 0 var(--gap-sm);
font-size: 0.875rem;
font-weight: 600;
}
.settings-category-button.is-active {
background: var(--color-button-bg-selected);
color: var(--color-button-text-selected);
}
.settings-search-result-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
}
.settings-search-result-label {
overflow: hidden;
color: var(--color-contrast);
font-size: 0.875rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-search-match {
background: transparent;
color: var(--color-brand);
padding: 0;
}
.settings-content {
display: flex;
min-width: 0;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.settings-content-fallback {
min-height: 100%;
}
.settings-footer-identity {
min-width: 0;
}
.settings-footer-version {
min-width: 0;
line-height: 1.4;
}
.settings-content-header {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--gap-md);
padding: var(--gap-xs) var(--gap-xl) var(--gap-lg);
}
.settings-content-scroll :deep([id^='settings-target-']),
.settings-content-scroll :deep([id^='settings-category-']) {
scroll-margin-top: 1.5rem;
}
.settings-content-scroll :deep(.settings-search-result-highlight) {
border-radius: var(--radius-sm);
animation: settings-search-result-highlight 0.9s ease-in-out 2;
}
@keyframes settings-search-result-highlight {
0%,
100% {
background: transparent;
}
50% {
background: var(--surface-3);
}
}
@media (max-width: 800px) {
.settings-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
}
.settings-sidebar {
height: auto;
border-right: 0;
padding-bottom: var(--gap-lg);
}
.settings-sidebar-list {
max-height: 18rem;
overflow-y: auto;
}
.settings-content-header {
padding-inline: var(--gap-lg);
}
}
</style>
<style>
.app-viewport:has(.settings-fixed-render) {
overflow: hidden;
scrollbar-gutter: auto;
}
.app-viewport:has(.settings-fixed-render) .page-transition-grid,
.app-viewport:has(.settings-fixed-render) .page-transition-layer {
height: 100%;
min-height: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const messages = defineMessages({
worlds: { id: 'app.worlds.title', defaultMessage: 'Worlds' },
})
</script>
<template>
<div class="p-6 flex flex-col gap-2">{{ formatMessage(messages.worlds) }}</div>
</template>

View File

@ -0,0 +1,89 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
import {
createDownloadFocusState,
focusedDownloadJobId,
reconcileDownloadFocus,
stopDownloadFocusAutoFollow,
} from './download-focus.ts'
function job(status: InstallJobStatus): InstallJobSnapshot {
return { job_id: 'job-a', status } as InstallJobSnapshot
}
test('focused active job selects Active and expands once', () => {
const effect = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('running'))
assert.equal(effect.tab, 'active')
assert.equal(effect.expand, true)
assert.equal(effect.scroll, true)
assert.equal(effect.state.autoFollow, true)
})
test('focused terminal job selects History and expands once', () => {
const effect = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('succeeded'))
assert.equal(effect.tab, 'history')
assert.equal(effect.expand, true)
assert.equal(effect.state.autoFollow, false)
})
test('active focused job follows completion to History once', () => {
const active = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('running'))
const completed = reconcileDownloadFocus(active.state, job('succeeded'))
assert.equal(completed.tab, 'history')
assert.equal(completed.expand, false)
assert.equal(completed.scroll, false)
assert.equal(completed.state.autoFollow, false)
assert.equal(reconcileDownloadFocus(completed.state, job('succeeded')).tab, null)
})
test('manual tab selection stops completion auto-follow', () => {
const active = reconcileDownloadFocus(createDownloadFocusState('job-a'), job('running'))
const manual = stopDownloadFocusAutoFollow(active.state)
assert.equal(reconcileDownloadFocus(manual, job('succeeded')).tab, null)
})
test('malformed or nonexistent focus leaves Downloads unchanged', () => {
assert.equal(focusedDownloadJobId(undefined), null)
assert.equal(focusedDownloadJobId(['job-a']), null)
assert.equal(focusedDownloadJobId(''), null)
const state = createDownloadFocusState('missing')
assert.deepEqual(reconcileDownloadFocus(state, null), {
state,
tab: null,
expand: false,
scroll: false,
})
})
test('Downloads focus reuses global manager without another install job listener', () => {
const source = readFileSync(new URL('./Downloads.vue', import.meta.url), 'utf8')
assert.doesNotMatch(source, /install_job_listener/)
assert.doesNotMatch(source, /focusedJobId === job\.job_id/)
assert.doesNotMatch(source, /ring-brand/)
})
test('Downloads covers upgrade phases and safely falls back for unknown phases', () => {
const source = readFileSync(new URL('./Downloads.vue', import.meta.url), 'utf8')
const phaseMessages = source.slice(
source.indexOf('const phaseMessages ='),
source.indexOf('const legacyDownloads'),
)
for (const phase of ['applying_content', 'verifying', 'completed']) {
assert.match(phaseMessages, new RegExp(`\\b${phase}:`))
}
assert.match(phaseMessages, /satisfies Record<InstallPhaseId, MessageDescriptor>/)
assert.match(source, /message \?\? messages\.unknownPhase/)
})
test('Downloads history links persisted upgrade result to standalone page', () => {
const source = readFileSync(new URL('./Downloads.vue', import.meta.url), 'utf8')
assert.match(source, /tab === 'history' && isSuccessfulUpgradeJob\(job\)/)
assert.match(source, /router\.push\(upgradeResultLocation\(job\)\)/)
assert.match(source, /messages\.viewUpgradeResult/)
assert.doesNotMatch(source, /UpgradeResultDetails/)
})

View File

@ -0,0 +1,85 @@
import type { InstallJobSnapshot, InstallJobStatus } from '@/helpers/install'
export type DownloadsTab = 'active' | 'history'
export interface DownloadFocusState {
jobId: string | null
focused: boolean
autoFollow: boolean
lastStatus: InstallJobStatus | null
}
export interface DownloadFocusEffect {
state: DownloadFocusState
tab: DownloadsTab | null
expand: boolean
scroll: boolean
}
const ACTIVE_STATUSES = new Set<InstallJobStatus>([
'queued',
'running',
'canceling',
'waiting_for_user',
])
export function focusedDownloadJobId(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
export function createDownloadFocusState(jobId: string | null): DownloadFocusState {
return {
jobId,
focused: false,
autoFollow: jobId !== null,
lastStatus: null,
}
}
export function downloadTabForJob(job: InstallJobSnapshot): DownloadsTab {
return ACTIVE_STATUSES.has(job.status) ? 'active' : 'history'
}
export function reconcileDownloadFocus(
state: DownloadFocusState,
job: InstallJobSnapshot | null,
): DownloadFocusEffect {
if (!state.jobId || !job || job.job_id !== state.jobId) {
return { state, tab: null, expand: false, scroll: false }
}
const targetTab = downloadTabForJob(job)
if (!state.focused) {
return {
state: {
...state,
focused: true,
autoFollow: targetTab === 'active',
lastStatus: job.status,
},
tab: targetTab,
expand: true,
scroll: true,
}
}
const completedWhileFollowing =
state.autoFollow &&
state.lastStatus !== null &&
ACTIVE_STATUSES.has(state.lastStatus) &&
targetTab === 'history'
return {
state: {
...state,
autoFollow: completedWhileFollowing ? false : state.autoFollow,
lastStatus: job.status,
},
tab: completedWhileFollowing ? 'history' : null,
expand: false,
scroll: false,
}
}
export function stopDownloadFocusAutoFollow(state: DownloadFocusState): DownloadFocusState {
return { ...state, autoFollow: false }
}

View File

@ -0,0 +1,207 @@
<template>
<div class="flex flex-col gap-3 p-6">
<h1 class="text-lg font-bold text-contrast">
{{ formatMessage(messages.title) }}
</h1>
<div class="flex flex-col gap-2">
<BigOptionButton
v-for="section in sections"
:key="section.key"
:icon="section.icon"
:title="formatMessage(section.titleMsg)"
:description="formatMessage(section.descMsg)"
:note="formatMessage(section.formatsMsg)"
no-icon-border
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
BookIcon,
BracesIcon,
FolderOpenIcon,
GridIcon,
MapIcon,
PackageOpenIcon,
PaletteIcon,
SparklesIcon,
} from '@modrinth/assets'
import { BigOptionButton, defineMessages, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'drop.help.title',
defaultMessage: 'What can I drop into Axolotl?',
},
instancesTitle: {
id: 'drop.help.instances-title',
defaultMessage: 'Launcher Instances',
},
instancesDesc: {
id: 'drop.help.instances-desc',
defaultMessage:
'Drag folders from other launchers (MultiMC, Prism Launcher, HMCL, etc.) to import Minecraft instances.',
},
instancesFormats: {
id: 'drop.help.instances-formats',
defaultMessage: 'MultiMC / Prism Launcher / PolyMC / HMCL instance folders',
},
modsTitle: {
id: 'drop.help.mods-title',
defaultMessage: 'Mods',
},
modsDesc: {
id: 'drop.help.mods-desc',
defaultMessage: 'Drag mod JAR files to install them into a Minecraft instance.',
},
modsFormats: {
id: 'drop.help.mods-formats',
defaultMessage: '.jar',
},
modpacksTitle: {
id: 'drop.help.modpacks-title',
defaultMessage: 'Modpacks',
},
modpacksDesc: {
id: 'drop.help.modpacks-desc',
defaultMessage: 'Drag modpack ZIP/MRPACK files to create a new instance.',
},
modpacksFormats: {
id: 'drop.help.modpacks-formats',
defaultMessage: '.mrpack / .zip',
},
resourcePacksTitle: {
id: 'drop.help.resource-packs-title',
defaultMessage: 'Resource Packs',
},
resourcePacksDesc: {
id: 'drop.help.resource-packs-desc',
defaultMessage: 'Drag resource pack ZIP files to install them into an instance.',
},
resourcePacksFormats: {
id: 'drop.help.resource-packs-formats',
defaultMessage: '.zip',
},
dataPacksTitle: {
id: 'drop.help.data-packs-title',
defaultMessage: 'Data Packs',
},
dataPacksDesc: {
id: 'drop.help.data-packs-desc',
defaultMessage: "Drag data pack ZIP files to install them into a world's datapacks folder.",
},
dataPacksFormats: {
id: 'drop.help.data-packs-formats',
defaultMessage: '.zip',
},
shaderPacksTitle: {
id: 'drop.help.shader-packs-title',
defaultMessage: 'Shader Packs',
},
shaderPacksDesc: {
id: 'drop.help.shader-packs-desc',
defaultMessage: 'Drag shader pack ZIP files to install them into an instance.',
},
shaderPacksFormats: {
id: 'drop.help.shader-packs-formats',
defaultMessage: '.zip',
},
worldsTitle: {
id: 'drop.help.worlds-title',
defaultMessage: 'World Saves',
},
worldsDesc: {
id: 'drop.help.worlds-desc',
defaultMessage: 'Drag world save folders to import them into an instance.',
},
worldsFormats: {
id: 'drop.help.worlds-formats',
defaultMessage: 'Minecraft world save folder (level.dat)',
},
schematicsTitle: {
id: 'drop.help.schematics-title',
defaultMessage: 'Schematics',
},
schematicsDesc: {
id: 'drop.help.schematics-desc',
defaultMessage: 'Drag .litematic files to import them as schematics.',
},
schematicsFormats: {
id: 'drop.help.schematics-formats',
defaultMessage: '.litematic',
},
})
interface HelpSection {
key: string
icon: Component
titleMsg: { id: string; defaultMessage: string }
descMsg: { id: string; defaultMessage: string }
formatsMsg: { id: string; defaultMessage: string }
}
const sections: HelpSection[] = [
{
key: 'instances',
icon: FolderOpenIcon,
titleMsg: messages.instancesTitle,
descMsg: messages.instancesDesc,
formatsMsg: messages.instancesFormats,
},
{
key: 'mods',
icon: PackageOpenIcon,
titleMsg: messages.modsTitle,
descMsg: messages.modsDesc,
formatsMsg: messages.modsFormats,
},
{
key: 'modpacks',
icon: GridIcon,
titleMsg: messages.modpacksTitle,
descMsg: messages.modpacksDesc,
formatsMsg: messages.modpacksFormats,
},
{
key: 'resource-packs',
icon: PaletteIcon,
titleMsg: messages.resourcePacksTitle,
descMsg: messages.resourcePacksDesc,
formatsMsg: messages.resourcePacksFormats,
},
{
key: 'data-packs',
icon: BracesIcon,
titleMsg: messages.dataPacksTitle,
descMsg: messages.dataPacksDesc,
formatsMsg: messages.dataPacksFormats,
},
{
key: 'shader-packs',
icon: SparklesIcon,
titleMsg: messages.shaderPacksTitle,
descMsg: messages.shaderPacksDesc,
formatsMsg: messages.shaderPacksFormats,
},
{
key: 'worlds',
icon: MapIcon,
titleMsg: messages.worldsTitle,
descMsg: messages.worldsDesc,
formatsMsg: messages.worldsFormats,
},
{
key: 'schematics',
icon: BookIcon,
titleMsg: messages.schematicsTitle,
descMsg: messages.schematicsDesc,
formatsMsg: messages.schematicsFormats,
},
]
</script>

View File

@ -0,0 +1 @@
export { default as DropHelp } from './DropHelp.vue'

View File

@ -0,0 +1,25 @@
import Browse from './Browse.vue'
import Downloads from './Downloads.vue'
import Index from './Index.vue'
import Lab from './Lab.vue'
import LabGradientText from './LabGradientText.vue'
import LabRecipeGenerator from './LabRecipeGenerator.vue'
import LabSchematicPreview from './LabSchematicPreview.vue'
import LabSeedMap from './LabSeedMap.vue'
import Multiplayer from './Multiplayer.vue'
import Skins from './Skins.vue'
import Worlds from './Worlds.vue'
export {
Browse,
Downloads,
Index,
Lab,
LabGradientText,
LabRecipeGenerator,
LabSchematicPreview,
LabSeedMap,
Multiplayer,
Skins,
Worlds,
}

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

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.link)"
/>
</template>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.link)"
/>
</template>

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import { PlusIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NavTabs,
useVIntl,
} from '@modrinth/ui'
import { onUnmounted, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
const { handleError } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const { formatMessage } = useVIntl()
const messages = defineMessages({
library: { id: 'app.library.title', defaultMessage: 'Library' },
allInstances: { id: 'app.library.tabs.all-instances', defaultMessage: 'All instances' },
modpacks: { id: 'app.library.tabs.modpacks', defaultMessage: 'Modpacks' },
servers: { id: 'app.library.tabs.servers', defaultMessage: 'Servers' },
custom: { id: 'app.library.tabs.custom', defaultMessage: 'Custom' },
shared: { id: 'app.library.tabs.shared', defaultMessage: 'Shared with me' },
saved: { id: 'app.library.tabs.saved', defaultMessage: 'Saved' },
noInstances: { id: 'app.library.no-instances', defaultMessage: 'No instances found' },
createInstance: {
id: 'app.library.create-instance',
defaultMessage: 'Create new instance',
},
})
breadcrumbs.setRootContext({ name: formatMessage(messages.library), link: route.path })
const instances = shallowRef(await list().catch(handleError))
const refreshInstances = async () => {
instances.value = await list().catch(handleError)
}
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, refreshInstances)
const { offline } = useNetworkStatus()
const unlistenInstance = await instance_listener(async () => {
await refreshInstances()
})
onUnmounted(() => {
unlistenInstance()
window.removeEventListener(DIRECT_LINKS_SYNCED_EVENT, refreshInstances)
})
</script>
<template>
<div data-onboarding-id="library-content" class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">{{ formatMessage(messages.library) }}</h1>
<NavTabs
:links="[
{ label: formatMessage(messages.allInstances), href: `/library` },
{ label: formatMessage(messages.modpacks), href: `/library/modpacks` },
{ label: formatMessage(messages.servers), href: `/library/servers` },
{ label: formatMessage(messages.custom), href: `/library/custom` },
{ label: formatMessage(messages.shared), href: `/library/shared`, shown: false },
{ label: formatMessage(messages.saved), href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance flex flex-col items-center justify-center h-full gap-3">
<div class="icon">
<NewInstanceImage />
</div>
<h3>{{ formatMessage(messages.noInstances) }}</h3>
<ButtonStyled color="brand">
<button
data-onboarding-id="create-instance"
:disabled="offline"
@click="router.push('/create')"
>
<PlusIcon />
{{ formatMessage(messages.createInstance) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<style lang="scss" scoped>
.no-instance {
p,
h3 {
margin: 0;
}
.icon {
svg {
width: 10rem;
height: 10rem;
}
}
}
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { get_project_v3_many } from '@/helpers/cache.js'
const props = defineProps({
instances: {
type: Array,
required: true,
},
})
const serverProjectIds = ref(new Set())
const linkedInstances = computed(() => props.instances.filter((i) => i.link))
watchEffect(async () => {
const projectIds = [
...new Set(linkedInstances.value.map((i) => i.link?.project_id).filter(Boolean)),
]
if (projectIds.length === 0) {
serverProjectIds.value = new Set()
return
}
try {
const projects = await get_project_v3_many(projectIds, 'must_revalidate')
serverProjectIds.value = new Set(
projects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
} catch {
serverProjectIds.value = new Set()
}
})
const filteredInstances = computed(() =>
linkedInstances.value.filter((i) => !serverProjectIds.value.has(i.link?.project_id)),
)
</script>
<template>
<GridDisplay
v-if="filteredInstances && filteredInstances.length > 0"
label="Instances"
:instances="filteredInstances"
/>
</template>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { get_project_v3_many } from '@/helpers/cache.js'
const props = defineProps({
instances: {
type: Array,
required: true,
},
})
const serverProjectIds = ref(new Set())
const linkedInstances = computed(() => props.instances.filter((i) => i.link))
watchEffect(async () => {
const projectIds = [
...new Set(linkedInstances.value.map((i) => i.link?.project_id).filter(Boolean)),
]
if (projectIds.length === 0) {
serverProjectIds.value = new Set()
return
}
try {
const projects = await get_project_v3_many(projectIds, 'must_revalidate')
serverProjectIds.value = new Set(
projects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
} catch {
serverProjectIds.value = new Set()
}
})
const filteredInstances = computed(() =>
linkedInstances.value.filter((i) => serverProjectIds.value.has(i.link?.project_id)),
)
</script>
<template>
<GridDisplay
v-if="filteredInstances && filteredInstances.length > 0"
label="Instances"
:instances="filteredInstances"
/>
</template>

View File

@ -0,0 +1,8 @@
import Custom from './Custom.vue'
import Downloaded from './Downloaded.vue'
import Index from './Index.vue'
import Modpacks from './Modpacks.vue'
import Overview from './Overview.vue'
import Servers from './Servers.vue'
export { Custom, Downloaded, Index, Modpacks, Overview, Servers }

View File

@ -0,0 +1,11 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Changelog',
}
</script>
<style scoped></style>

View File

@ -0,0 +1,964 @@
<template>
<div v-if="loading" class="flex min-h-64 items-center justify-center gap-3 p-6 text-secondary">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="data">
<UpgradeProjectReturnBar />
<Teleport to="#sidebar-teleport-target">
<ProjectSidebarCompatibility
:project="data"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:platform-action="(platform) => browseByProjectFilter('loader', platform)"
class="project-sidebar-section"
/>
<ProjectSidebarLinks
link-target="_blank"
:project="data"
:mcmod-url="mcmodUrl"
class="project-sidebar-section"
/>
<ProjectSidebarTags
:project="data"
:tag-action="(tag) => browseByProjectFilter('category', tag)"
class="project-sidebar-section"
/>
<ProjectSidebarCreators
:members="members"
:org-link="() => data.links.website_url"
:user-link="(username) => authorLinks[username] ?? data.links.website_url"
link-target="_blank"
class="project-sidebar-section"
/>
<ProjectSidebarDetails
:project="data"
:has-versions="versions.length > 0"
hide-license
link-target="_blank"
class="project-sidebar-section"
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<Teleport v-if="themeStore.featureFlags.project_background" to="#background-teleport-target">
<ProjectBackgroundGradient :project="data" />
</Teleport>
<BrowseInstallHeader
v-if="fromInstanceContent && cartInstallContext"
:install-context="cartInstallContext"
/>
<ProjectHeader
:project="data"
:show-followers="false"
:translated-title="translationActive ? translations.title : undefined"
:translated-description="translationActive ? translations.description : undefined"
:translation-mode="translationMode"
:translation-style="translationStyle"
>
<template #actions>
<ButtonStyled size="large" type="transparent">
<button :disabled="translationLoading" @click="toggleTranslation">
<SpinnerIcon v-if="translationLoading" class="animate-spin" />
<LanguagesIcon v-else />
{{
formatMessage(
translationLoading
? messages.translating
: translationActive
? messages.showOriginal
: messages.translateProject,
)
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="managedProjectType || isWorldMap" size="large" color="brand">
<button :disabled="installing || cartProjectInstalling" @click="installSelected(null)">
<SpinnerIcon v-if="installing" class="animate-spin" />
<PlusIcon v-else-if="isWorldMap || cartProjectSelected" />
<DownloadIcon v-else />
{{
formatMessage(
installing || cartProjectInstalling
? commonMessages.installingLabel
: cartProjectSelected
? commonMessages.selectedLabel
: isWorldMap
? instanceId
? commonMessages.installButton
: messages.addToAnInstance
: commonMessages.installButton,
)
}}
</button>
</ButtonStyled>
<ButtonStyled
v-if="data.site_url || mcmodUrl || favoriteSupported"
size="large"
circular
type="transparent"
>
<OverflowMenu
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
...(favoriteSupported
? [
{
id: 'save',
disabled: favoritePending,
tooltip: formatMessage(
favoriteSaved ? messages.removeFromFavorites : messages.addToFavorites,
),
action: () => toggleFavorite(),
},
]
: []),
...(data.site_url
? [
{
id: 'open-in-browser',
link: data.site_url,
external: true,
},
]
: []),
...(mcmodUrl
? [
{
id: 'open-in-mcmod',
link: mcmodUrl,
external: true,
},
]
: []),
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #open-in-mcmod>
<BookOpenIcon /> {{ formatMessage(messages.openInMcmod) }}
</template>
<template v-if="favoriteSupported" #save>
<BookmarkFilledIcon v-if="favoriteSaved" class="text-brand" />
<BookmarkIcon v-else />
{{
formatMessage(
favoritePending
? messages.favoritesLoading
: favoriteSaved
? messages.removeFromFavorites
: messages.addToFavorites,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
<SelectedProjectsFloatingBar
v-if="cartInstallContext"
:install-context="cartInstallContext"
/>
<BrowseInstanceSelector
ref="browseInstanceSelector"
:instances="contentSelection.instances.value"
:selected-instance="contentSelection.targetInstance.value"
:selected-count="contentSelection.selectedCount.value"
:install-current="contentSelection.installSelected"
:clear-current="contentSelection.clear"
@select="contentSelection.setTarget"
/>
<NavTabs
:links="[
{
label: formatMessage(messages.description),
href: projectDescriptionHref,
},
{
label: formatMessage(messages.versions),
href: projectVersionsHref,
},
{
label: formatMessage(messages.gallery),
href: projectGalleryHref,
shown: data.gallery.length > 0,
},
]"
/>
<Gallery
v-if="activeTab === 'gallery'"
:project="data"
:translation-active="translationActive"
:translations="translations"
:translation-mode="translationMode"
:translation-style="translationStyle"
/>
<ProjectPageVersions
v-else-if="activeTab === 'versions'"
:loaders="allLoaders"
:game-versions="allGameVersions"
:versions="versions"
:project="data"
:show-environment-column="themeStore.featureFlags.show_version_environment_column"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent" :color="isWorldMap ? 'brand' : 'green'">
<button
v-tooltip="
formatMessage(isWorldMap ? messages.addToAnInstance : commonMessages.installButton)
"
:disabled="installing"
@click.stop="installSelected(version.id)"
>
<PlusIcon v-if="isWorldMap" />
<DownloadIcon v-else />
</button>
</ButtonStyled>
</template>
</ProjectPageVersions>
<Card v-else>
<TranslatedProjectDescription
v-if="data.body"
:description="data.body"
:active="translationActive"
:translations="translations"
:mode="translationMode"
:style="translationStyle"
format="html"
/>
<p v-else class="m-0">{{ data.description }}</p>
</Card>
</div>
</div>
<div v-else class="p-6">
<Card>
<h2>{{ formatMessage(messages.unavailableTitle) }}</h2>
<p class="mb-0">{{ formatMessage(messages.unavailableDescription) }}</p>
</Card>
</div>
</template>
<script setup lang="ts">
import {
BookmarkFilledIcon,
BookmarkIcon,
BookOpenIcon,
DownloadIcon,
ExternalIcon,
LanguagesIcon,
MoreVerticalIcon,
PlusIcon,
SpinnerIcon,
} from '@modrinth/assets'
import {
BrowseInstallHeader,
ButtonStyled,
Card,
commonMessages,
defineMessages,
injectNotificationManager,
NavTabs,
OverflowMenu,
ProjectBackgroundGradient,
ProjectHeader,
ProjectPageVersions,
ProjectSidebarCompatibility,
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectSidebarTags,
SelectedProjectsFloatingBar,
usesTargetGameVersion,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
import TranslatedProjectDescription from '@/components/ui/TranslatedProjectDescription.vue'
import { useContentFavorites } from '@/composables/useContentFavorites'
import { isFavoriteContentType } from '@/helpers/content-favorites'
import { resolveMcmodUrl } from '@/helpers/content-search'
import {
type CurseForgeFile,
type CurseForgeProject,
getCurseForgeDescription,
getCurseForgeFiles,
getCurseForgeImageUrl,
getCurseForgeProject,
} from '@/helpers/curseforge'
import { projectGalleryTranslationSegments } from '@/helpers/project-gallery'
import { createProjectBrowseLocation, type ProjectBrowseFilter } from '@/helpers/project-links'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import {
getTranslationErrorKind,
getTranslationSettings,
prepareDescription,
translateInBatches as translateContent,
type TranslationStyle,
validateTranslatedDescription,
} from '@/helpers/translation'
import i18n from '@/i18n.config'
import { injectContentInstall } from '@/providers/content-install'
import { injectContentSelection, makeContentSelectionKey } from '@/providers/content-selection'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state.js'
import Gallery from './Gallery.vue'
import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { addNotification, handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { installCurseForge, installCurseForgeWorld } = injectContentInstall()
const contentSelection = injectContentSelection()
const contentFavorites = useContentFavorites()
void contentFavorites.load().catch(handleError)
const messages = defineMessages({
loading: {
id: 'app.project.curseforge.loading',
defaultMessage: 'Loading CurseForge project…',
},
openInMcmod: {
id: 'app.project.open-in-mcmod',
defaultMessage: 'Open in MC Mod',
},
description: {
id: 'project.description.title',
defaultMessage: 'Description',
},
versions: {
id: 'project.versions.title',
defaultMessage: 'Versions',
},
gallery: {
id: 'project.gallery.title',
defaultMessage: 'Gallery',
},
translateProject: {
id: 'app.project.translation.translate',
defaultMessage: 'Translate',
},
showOriginal: {
id: 'app.project.translation.show-original',
defaultMessage: 'Show original',
},
translating: {
id: 'app.project.translation.translating',
defaultMessage: 'Translating…',
},
translationFailed: {
id: 'app.project.translation.failed',
defaultMessage: 'Translation failed. The original content was kept. Try again.',
},
translationFailedTitle: {
id: 'app.project.translation.failed-title',
defaultMessage: 'Translation failed',
},
translationRateLimited: {
id: 'app.translation.error.rate-limited',
defaultMessage: 'The translation service is temporarily rate limited. Please try again later.',
},
translationAuthenticationFailed: {
id: 'app.translation.error.authentication',
defaultMessage: 'The translation service could not authenticate. Please try again later.',
},
translationContentTooLong: {
id: 'app.translation.error.content-too-long',
defaultMessage: 'This content is too long for the selected translation service.',
},
translationNetworkFailed: {
id: 'app.translation.error.network',
defaultMessage: 'The translation service could not be reached. Check your network or proxy.',
},
unavailableTitle: {
id: 'app.project.curseforge.unavailable-title',
defaultMessage: 'Project unavailable',
},
unavailableDescription: {
id: 'app.project.curseforge.unavailable-description',
defaultMessage: 'The CurseForge project did not return any data.',
},
addToAnInstance: {
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
noCompatibleVersion: {
id: 'app.project.install-button.no-compatible-version',
defaultMessage: 'No compatible version was found for this instance.',
},
backToInstanceContent: {
id: 'app.project.install-context.back-to-instance-content',
defaultMessage: 'Back to instance content',
},
addToFavorites: {
id: 'app.content-favorites.add',
defaultMessage: 'Add to favorites',
},
removeFromFavorites: {
id: 'app.content-favorites.remove',
defaultMessage: 'Remove from favorites',
},
favoritesLoading: {
id: 'app.content-favorites.loading',
defaultMessage: 'Updating favorites…',
},
})
const loading = ref(true)
const installing = ref(false)
const browseInstanceSelector = ref()
const project = shallowRef<CurseForgeProject | null>(null)
const mcmodUrl = ref<string | null>(null)
const description = ref('')
const files = shallowRef<CurseForgeFile[]>([])
const allLoaders = ref([])
const allGameVersions = ref([])
const translationActive = ref(false)
const translationLoading = ref(false)
const translations = ref<Record<string, string>>({})
const translationMode = ref<'bilingual' | 'translation-only'>('bilingual')
const translationStyle = ref<TranslationStyle>('weakened')
let projectRequestVersion = 0
let translationRequestVersion = 0
const projectType = computed(() => {
switch (project.value?.classId) {
case 5:
return 'plugin'
case 6:
return 'mod'
case 12:
return 'resourcepack'
case 17:
return 'world'
case 6945:
return 'datapack'
case 4471:
return 'modpack'
case 6552:
return 'shader'
default:
return 'mod'
}
})
const favoriteSupported = computed(() => isFavoriteContentType(projectType.value))
const favoriteProjectId = computed(() => project.value?.id.toString() ?? '')
const favoriteSaved = computed(() =>
favoriteProjectId.value
? contentFavorites.isFavorite('curseforge', favoriteProjectId.value)
: false,
)
const favoritePending = computed(() =>
favoriteProjectId.value
? contentFavorites.isPending('curseforge', favoriteProjectId.value)
: false,
)
function toggleFavorite() {
if (!favoriteSupported.value || !favoriteProjectId.value || favoritePending.value) return
void contentFavorites
.toggle({
provider: 'curseforge',
project_id: favoriteProjectId.value,
content_type: projectType.value,
})
.catch(handleError)
}
const managedProjectType = computed(() =>
['mod', 'resourcepack', 'shader', 'datapack', 'modpack'].includes(projectType.value),
)
const isWorldMap = computed(() => projectType.value === 'world')
const instanceId = computed(() => (typeof route.query.i === 'string' ? route.query.i : null))
const fromBrowse = computed(
() => typeof route.query.b === 'string' && route.query.b.startsWith('/browse/'),
)
const fromInstanceContent = computed(
() => route.query.from === 'instance-content' && instanceId.value !== null,
)
const instanceContentTarget = computed(() => {
if (!fromInstanceContent.value) return null
const target = contentSelection.targetInstance.value
return target?.id === instanceId.value ? target : null
})
const instanceContentBackUrl = computed(() =>
instanceContentTarget.value ? `/instance/${encodeURIComponent(instanceId.value!)}` : null,
)
const cartEligible = computed(
() =>
(fromBrowse.value || instanceContentTarget.value !== null) &&
['mod', 'resourcepack', 'shader', 'datapack', 'world'].includes(projectType.value),
)
const cartProjectKey = computed(() =>
project.value ? makeContentSelectionKey('curseforge', project.value.id.toString()) : '',
)
const cartProjectSelected = computed(
() => !!cartProjectKey.value && contentSelection.isSelected(cartProjectKey.value),
)
const cartProjectInstalling = computed(
() => !!cartProjectKey.value && contentSelection.isInstalling(cartProjectKey.value),
)
const cartInstallContext = computed(() => {
const target = contentSelection.targetInstance.value
if (!cartEligible.value || !target) return null
return {
showInstallHeader: false,
name: target.name,
loader: target.loader,
gameVersion: target.game_version,
backUrl:
instanceContentBackUrl.value ??
(typeof route.query.b === 'string' ? route.query.b : `/browse/${projectType.value}`),
backLabel: fromInstanceContent.value ? formatMessage(messages.backToInstanceContent) : '',
heading: '',
selectedProjects: contentSelection.selectedProjects.value,
isInstallingSelected: ['validating', 'reviewing', 'queueing'].includes(
contentSelection.state.value,
),
installProgress: contentSelection.progress.value,
clearSelected: contentSelection.clear,
installSelected: contentSelection.installSelected,
}
})
const platformNames = [
'forge',
'fabric',
'quilt',
'neoforge',
'liteloader',
'rift',
'iris',
'optifine',
]
const loaderTypes: Record<number, string> = { 1: 'forge', 4: 'fabric', 5: 'quilt', 6: 'neoforge' }
function getFilePlatforms(file: CurseForgeFile) {
const platforms = file.gameVersions
.map((version) => version.toLowerCase().replaceAll(' ', ''))
.filter((version) => platformNames.includes(version))
if (platforms.length === 0 && projectType.value === 'resourcepack') {
return ['minecraft']
}
return platforms
}
const projectLoaders = computed(() => {
const loaders = new Set<string>()
for (const file of files.value) {
for (const platform of getFilePlatforms(file)) loaders.add(platform)
}
for (const index of project.value?.latestFilesIndexes ?? []) {
if (index.modLoader && loaderTypes[index.modLoader]) loaders.add(loaderTypes[index.modLoader])
}
return [...loaders]
})
const minecraftVersions = computed(() => {
const versions = new Set<string>()
for (const file of files.value) {
for (const version of file.gameVersions) {
if (/^\d+\.\d+/.test(version)) versions.add(version)
}
}
for (const index of project.value?.latestFilesIndexes ?? []) {
if (/^\d+\.\d+/.test(index.gameVersion)) versions.add(index.gameVersion)
}
return [...versions]
})
const data = computed(() => {
if (!project.value) return null
const value = project.value
return {
id: value.id.toString(),
slug: value.slug,
title: value.name,
description: value.summary,
body: description.value,
project_type: projectType.value,
downloads: value.downloadCount,
followers: 0,
icon_url: getCurseForgeImageUrl(value.logo?.thumbnailUrl),
color: null,
status: 'approved',
categories: value.categories.map((category) => category.slug),
additional_categories: [],
versions: files.value.map((file) => file.id.toString()),
game_versions: minecraftVersions.value,
loaders: projectLoaders.value,
client_side: 'unknown',
server_side: 'unknown',
published: value.dateCreated,
approved: value.dateReleased || value.dateCreated,
updated: value.dateModified,
queued: null,
license: { id: 'LicenseRef-Unknown', name: 'Unknown', url: null },
issues_url: value.links.issuesUrl ?? '',
source_url: value.links.sourceUrl ?? '',
wiki_url: value.links.wikiUrl ?? '',
discord_url: '',
site_url: value.links.websiteUrl ?? '',
donation_urls: [],
links: {
website_url: value.links.websiteUrl ?? '',
},
gallery: value.screenshots.map((screenshot) => ({
title: screenshot.title,
description: '',
created: value.dateModified,
url: getCurseForgeImageUrl(screenshot.thumbnailUrl, 960),
raw_url: getCurseForgeImageUrl(screenshot.url, 1920),
featured: false,
})),
}
})
function browseByProjectFilter(filter: ProjectBrowseFilter, value: string) {
if (!data.value?.project_type) return
void router.push(createProjectBrowseLocation(data.value.project_type, filter, value))
}
const members = computed(() =>
(project.value?.authors ?? []).map((author, index) => ({
id: author.id.toString(),
role: index === 0 ? 'Owner' : 'Author',
is_owner: index === 0,
accepted: true,
user: {
id: author.id.toString(),
username: author.name,
avatar_url: '',
},
})),
)
const authorLinks = computed(() =>
Object.fromEntries((project.value?.authors ?? []).map((author) => [author.name, author.url])),
)
const versions = computed(() =>
files.value.map((file) => {
const loaders = getFilePlatforms(file)
const gameVersions = file.gameVersions.filter((version) => /^\d+\.\d+/.test(version))
return {
id: file.id.toString(),
project_id: project.value?.id.toString() ?? '',
name: file.displayName,
version_number: file.displayName,
version_type: file.releaseType === 1 ? 'release' : file.releaseType === 2 ? 'beta' : 'alpha',
date_published: file.fileDate,
downloads: file.downloadCount,
game_versions: gameVersions,
loaders: loaders.length ? loaders : projectLoaders.value,
files: [
{
filename: file.fileName,
size: file.fileLength,
url: file.downloadUrl ?? '',
primary: true,
hashes: {},
},
],
featured: false,
status: 'listed',
changelog: '',
dependencies: [],
displayUrlEnding: file.id.toString(),
}
}),
)
const activeTab = computed(() => {
if (route.path.endsWith('/versions')) return 'versions'
if (route.path.endsWith('/gallery')) return 'gallery'
return 'description'
})
function buildProjectHref(path: string) {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(route.query)) {
if (Array.isArray(value)) {
for (const item of value) if (item) params.append(key, String(item))
} else if (value) {
params.append(key, String(value))
}
}
const query = params.toString()
return query ? `${path}?${query}` : path
}
const projectDescriptionHref = computed(() =>
buildProjectHref(`/project/curseforge/${route.params.id}`),
)
const projectVersionsHref = computed(() =>
buildProjectHref(`/project/curseforge/${route.params.id}/versions`),
)
const projectGalleryHref = computed(() =>
buildProjectHref(`/project/curseforge/${route.params.id}/gallery`),
)
async function loadProject(projectId: number) {
const requestVersion = ++projectRequestVersion
translationRequestVersion++
translationActive.value = false
translationLoading.value = false
translations.value = {}
loading.value = true
project.value = null
mcmodUrl.value = null
description.value = ''
files.value = []
allLoaders.value = []
allGameVersions.value = []
try {
const supplementaryData = Promise.allSettled([
getCurseForgeDescription(projectId),
getCurseForgeFiles(projectId, { index: 0, pageSize: 50 }),
get_loaders(),
get_game_versions(),
])
const projectData = await getCurseForgeProject(projectId)
if (requestVersion !== projectRequestVersion) return
project.value = projectData
breadcrumbs.setName('Project', projectData.name)
breadcrumbs.setNameIcon(
'Project',
projectData.logo?.thumbnailUrl ?? projectData.logo?.url ?? null,
)
loading.value = false
void resolveMcmodUrl(projectData.slug, 'curseforge').then((url) => {
if (requestVersion === projectRequestVersion) mcmodUrl.value = url
})
const [projectDescription, projectFiles, loaders, gameVersions] = await supplementaryData
if (requestVersion !== projectRequestVersion) return
if (projectDescription.status === 'fulfilled') {
description.value = projectDescription.value
} else {
handleError(projectDescription.reason)
}
if (projectFiles.status === 'fulfilled') {
files.value = projectFiles.value.files
} else {
handleError(projectFiles.reason)
}
if (loaders.status === 'fulfilled') allLoaders.value = loaders.value
if (gameVersions.status === 'fulfilled') allGameVersions.value = gameVersions.value
void maybeAutoTranslate()
} catch (error) {
if (requestVersion === projectRequestVersion) handleError(error)
} finally {
if (requestVersion === projectRequestVersion) loading.value = false
}
}
watch(
() => Number(route.params.id),
(projectId) => {
if (Number.isFinite(projectId)) void loadProject(projectId)
},
{ immediate: true },
)
watch(
[fromBrowse, fromInstanceContent, instanceId],
async ([fromBrowseEnabled, fromInstanceContentEnabled, preferredInstanceId]) => {
if (fromBrowseEnabled || fromInstanceContentEnabled) {
await contentSelection.refreshInstances(preferredInstanceId)
if (fromInstanceContentEnabled && preferredInstanceId) {
const preferredInstance = contentSelection.instances.value.find(
(instance) => instance.id === preferredInstanceId,
)
if (preferredInstance) contentSelection.setTarget(preferredInstance)
}
}
},
{ immediate: true },
)
async function installSelected(fileId: string | null) {
if (!project.value) return
if (cartEligible.value && !contentSelection.targetInstance.value) {
await contentSelection.refreshInstances()
browseInstanceSelector.value?.show()
return
}
if (cartEligible.value && contentSelection.targetInstance.value) {
if (cartProjectSelected.value) {
contentSelection.remove(cartProjectKey.value)
return
}
const target = contentSelection.targetInstance.value
const expectedLoader = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 }[target.loader]
let resolvedFileId: string | number | null = fileId
if (!resolvedFileId && !usesTargetGameVersion(projectType.value)) {
resolvedFileId = files.value.find((file) => file.isAvailable)?.id ?? null
} else if (!resolvedFileId) {
resolvedFileId =
project.value.latestFilesIndexes.find(
(index) =>
index.gameVersion === target.game_version &&
(projectType.value !== 'mod' || !expectedLoader || index.modLoader === expectedLoader),
)?.fileId ??
files.value.find(
(file) =>
file.gameVersions.includes(target.game_version) &&
(projectType.value !== 'mod' || getFilePlatforms(file).includes(target.loader)),
)?.id ??
null
}
if (!resolvedFileId) {
handleError(new Error(formatMessage(messages.noCompatibleVersion)))
return
}
await contentSelection.add({
key: cartProjectKey.value,
provider: 'curseforge',
projectId: project.value.id.toString(),
providerProjectId: project.value.id.toString(),
versionId: resolvedFileId.toString(),
contentType: projectType.value as 'mod' | 'resourcepack' | 'datapack' | 'shader' | 'world',
title: project.value.name,
iconUrl: getCurseForgeImageUrl(project.value.logo?.thumbnailUrl),
slug: project.value.slug,
preferences: {
gameVersions: usesTargetGameVersion(projectType.value) ? [target.game_version] : [],
loaders: projectType.value === 'mod' ? [target.loader] : [],
},
})
return
}
if (isWorldMap.value) {
installing.value = true
await installCurseForgeWorld(project.value.id, fileId, instanceId.value, 'ProjectPage', () => {
installing.value = false
}).catch((error) => {
installing.value = false
handleError(error)
})
return
}
installing.value = true
await installCurseForge(
project.value.id.toString(),
fileId,
instanceId.value,
'ProjectPage',
() => {
installing.value = false
},
(instanceId) => {
router.push(`/instance/${instanceId}`)
},
).catch((error) => {
installing.value = false
handleError(error)
})
}
function translationFailureMessage(error: unknown) {
return formatMessage(
{
'rate-limited': messages.translationRateLimited,
authentication: messages.translationAuthenticationFailed,
'content-too-long': messages.translationContentTooLong,
network: messages.translationNetworkFailed,
provider: messages.translationFailed,
}[getTranslationErrorKind(error)],
)
}
async function translateProject() {
if (!data.value || translationLoading.value) return
const requestVersion = ++translationRequestVersion
const previousTranslationActive = translationActive.value
const previousTranslations = translations.value
translationLoading.value = true
try {
const settings = await getTranslationSettings()
translationMode.value = settings.mode
translationStyle.value = settings.style
const prepared = prepareDescription(data.value.body ?? '', 'html')
const targetLanguage = settings.target_language || i18n.global.locale.value || 'en-US'
const baseRequest = {
source_language: 'auto',
target_language: targetLanguage,
context: {
title: data.value.title,
description: data.value.description,
},
}
const allSegments = [
{ id: 'title', text: data.value.title, format: 'plain' },
{ id: 'description', text: data.value.description, format: 'plain' },
...projectGalleryTranslationSegments(data.value.gallery),
...prepared.segments,
]
translationActive.value = true
const accumulated = { ...translations.value }
await translateContent({ ...baseRequest, segments: allSegments }, (response) => {
if (requestVersion !== translationRequestVersion) return
for (const segment of response.segments) accumulated[segment.id] = segment.text
translations.value = { ...accumulated }
})
if (requestVersion !== translationRequestVersion) return
validateTranslatedDescription(prepared, accumulated)
} catch (error) {
if (requestVersion === translationRequestVersion) {
translationActive.value = previousTranslationActive
translations.value = previousTranslations
addNotification({
title: formatMessage(messages.translationFailedTitle),
text: translationFailureMessage(error),
type: 'error',
})
}
} finally {
if (requestVersion === translationRequestVersion) translationLoading.value = false
}
}
async function maybeAutoTranslate() {
try {
const settings = await getTranslationSettings()
if (settings.auto_translate) await translateProject()
} catch (error) {
handleError(error)
}
}
function toggleTranslation() {
if (translationActive.value) {
translationRequestVersion++
translationActive.value = false
translationLoading.value = false
return
}
void translateProject()
}
</script>
<style scoped>
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid;
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<Card>
<TranslatedProjectDescription
:description="project.body"
:active="translationActive"
:translations="translations"
:mode="translationMode"
:style="translationStyle"
/>
</Card>
</template>
<script setup>
import { Card } from '@modrinth/ui'
import TranslatedProjectDescription from '@/components/ui/TranslatedProjectDescription.vue'
defineProps({
project: {
type: Object,
default: () => {},
},
translationActive: {
type: Boolean,
default: false,
},
translations: {
type: Object,
default: () => ({}),
},
translationMode: {
type: String,
default: 'bilingual',
},
translationStyle: {
type: String,
default: 'weakened',
},
})
</script>
<script>
export default {
name: 'Description',
}
</script>

View File

@ -0,0 +1,372 @@
<script setup lang="ts">
import {
CalendarIcon,
ContractIcon,
ExpandIcon,
ExternalIcon,
LeftArrowIcon,
RightArrowIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
commonProjectSettingsMessages,
defineMessages,
NewModal,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import {
type ProjectGalleryCaptionField,
projectGalleryTranslationSegmentId,
visibleProjectGallery,
} from '@/helpers/project-gallery'
import type { TranslationMode, TranslationStyle } from '@/helpers/translation'
interface GalleryImage {
url: string
raw_url?: string
title?: string
description?: string
created: string
}
interface GalleryEntry {
image: GalleryImage
index: number
}
const props = withDefaults(
defineProps<{
project: {
id: string
gallery?: GalleryImage[]
}
translationActive?: boolean
translations?: Record<string, string>
translationMode?: TranslationMode
translationStyle?: TranslationStyle
}>(),
{
translationActive: false,
translations: () => ({}),
translationMode: 'bilingual',
translationStyle: 'weakened',
},
)
const { formatMessage } = useVIntl()
const formatDate = useFormatDateTime({
year: 'numeric',
month: 'long',
day: 'numeric',
})
const screenshotMessages = defineMessages({
zoomIn: {
id: 'app.instance.screenshots.zoom-in',
defaultMessage: 'View at full size',
},
zoomOut: {
id: 'app.instance.screenshots.zoom-out',
defaultMessage: 'Fit to window',
},
})
const filteredGallery = computed<GalleryEntry[]>(() => visibleProjectGallery(props.project.gallery))
const selectedGalleryItem = ref<GalleryEntry | null>(null)
const zoomedIn = ref(false)
const viewerModal = ref<InstanceType<typeof NewModal>>()
const viewerTitle = computed(() => {
if (!selectedGalleryItem.value) return formatMessage(commonProjectSettingsMessages.gallery)
return (
galleryText(selectedGalleryItem.value, 'title') ||
formatMessage(commonProjectSettingsMessages.gallery)
)
})
const viewerImageUrl = computed(() => {
if (!selectedGalleryItem.value) return ''
return zoomedIn.value
? (selectedGalleryItem.value.image.raw_url ?? selectedGalleryItem.value.image.url)
: selectedGalleryItem.value.image.url
})
const translationClass = computed(() => [
'gallery-translation',
`gallery-translation--${props.translationStyle}`,
])
function translationFor(
entry: GalleryEntry,
field: ProjectGalleryCaptionField,
): string | undefined {
if (!props.translationActive) return undefined
return (
props.translations[projectGalleryTranslationSegmentId(entry.index, field)]?.trim() || undefined
)
}
function galleryText(entry: GalleryEntry, field: ProjectGalleryCaptionField): string {
const original = entry.image[field] ?? ''
const translated = translationFor(entry, field)
return props.translationMode === 'translation-only' && translated ? translated : original
}
function showBilingualTranslation(entry: GalleryEntry, field: ProjectGalleryCaptionField): boolean {
return props.translationMode === 'bilingual' && !!translationFor(entry, field)
}
function imageAlt(entry: GalleryEntry): string {
return galleryText(entry, 'title') || formatMessage(commonProjectSettingsMessages.gallery)
}
function viewImage(entry: GalleryEntry) {
selectedGalleryItem.value = entry
zoomedIn.value = false
viewerModal.value?.show()
trackEvent('GalleryImageExpand', {
project_id: props.project.id,
url: entry.image.url,
})
}
function changeImage(offset: number) {
if (!selectedGalleryItem.value || filteredGallery.value.length < 2) return
const currentIndex = filteredGallery.value.findIndex(
(entry) => entry.index === selectedGalleryItem.value?.index,
)
const nextIndex =
(currentIndex + offset + filteredGallery.value.length) % filteredGallery.value.length
selectedGalleryItem.value = filteredGallery.value[nextIndex]
zoomedIn.value = false
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
project_id: props.project.id,
url: selectedGalleryItem.value.image.url,
})
}
function handleViewerHide() {
selectedGalleryItem.value = null
zoomedIn.value = false
}
function keyListener(event: KeyboardEvent) {
if (!selectedGalleryItem.value) return
if (event.key === 'ArrowLeft') {
event.preventDefault()
changeImage(-1)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
changeImage(1)
}
}
onMounted(() => {
window.addEventListener('keydown', keyListener)
})
onUnmounted(() => {
window.removeEventListener('keydown', keyListener)
})
</script>
<template>
<div class="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-4">
<article
v-for="entry in filteredGallery"
:key="entry.image.url"
class="group overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-2 transition-colors hover:border-brand"
>
<button
class="relative block aspect-video w-full cursor-zoom-in overflow-hidden border-0 bg-surface-1 p-0"
:aria-label="formatMessage(commonMessages.viewLabel)"
@click="viewImage(entry)"
>
<img
:src="entry.image.url"
:alt="imageAlt(entry)"
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
/>
</button>
<div class="flex min-h-0 flex-1 flex-col gap-2 p-3">
<div
v-if="galleryText(entry, 'title') || galleryText(entry, 'description')"
class="min-w-0"
>
<h3
v-if="galleryText(entry, 'title')"
class="m-0 break-words font-semibold text-contrast"
>
{{ galleryText(entry, 'title') }}
</h3>
<p v-if="showBilingualTranslation(entry, 'title')" :class="translationClass">
{{ translationFor(entry, 'title') }}
</p>
<p v-if="galleryText(entry, 'description')" class="mb-0 mt-1 break-words text-secondary">
{{ galleryText(entry, 'description') }}
</p>
<p v-if="showBilingualTranslation(entry, 'description')" :class="translationClass">
{{ translationFor(entry, 'description') }}
</p>
</div>
<div class="mt-auto flex items-center gap-2 text-sm text-secondary">
<CalendarIcon class="size-4 shrink-0" aria-hidden="true" />
{{ formatDate(new Date(entry.image.created)) }}
</div>
</div>
</article>
</div>
<NewModal
ref="viewerModal"
:max-width="'92rem'"
:width="'calc(100vw - 4rem)'"
:no-padding="true"
:header="viewerTitle"
:on-hide="handleViewerHide"
>
<div
v-if="selectedGalleryItem"
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="viewerImageUrl"
:alt="imageAlt(selectedGalleryItem)"
:class="
zoomedIn
? 'max-w-none cursor-zoom-out'
: 'max-h-[calc(100vh-15rem)] max-w-full cursor-zoom-in'
"
@click="zoomedIn = !zoomedIn"
/>
</div>
<ButtonStyled v-if="filteredGallery.length > 1" circular>
<button
class="absolute left-4 top-1/2 -translate-y-1/2"
:aria-label="formatMessage(commonMessages.backButton)"
@click="changeImage(-1)"
>
<LeftArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="filteredGallery.length > 1" circular>
<button
class="absolute right-4 top-1/2 -translate-y-1/2"
:aria-label="formatMessage(commonMessages.nextButton)"
@click="changeImage(1)"
>
<RightArrowIcon />
</button>
</ButtonStyled>
</div>
<template #actions>
<div v-if="selectedGalleryItem" class="flex flex-wrap items-center justify-between gap-2">
<div class="min-w-0 text-sm text-secondary">
<p v-if="galleryText(selectedGalleryItem, 'description')" class="m-0 break-words">
{{ galleryText(selectedGalleryItem, 'description') }}
</p>
<p
v-if="showBilingualTranslation(selectedGalleryItem, 'description')"
:class="translationClass"
>
{{ translationFor(selectedGalleryItem, 'description') }}
</p>
<div class="mt-1 flex items-center gap-2">
<CalendarIcon class="size-4 shrink-0" aria-hidden="true" />
{{ formatDate(new Date(selectedGalleryItem.image.created)) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<ButtonStyled>
<button @click="zoomedIn = !zoomedIn">
<ContractIcon v-if="zoomedIn" />
<ExpandIcon v-else />
{{ formatMessage(zoomedIn ? screenshotMessages.zoomOut : screenshotMessages.zoomIn) }}
</button>
</ButtonStyled>
<ButtonStyled>
<a
target="_blank"
rel="noreferrer"
:href="selectedGalleryItem.image.raw_url ?? selectedGalleryItem.image.url"
>
<ExternalIcon />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</a>
</ButtonStyled>
</div>
</div>
</template>
</NewModal>
</template>
<style scoped>
.gallery-translation {
margin: 0.25rem 0 0;
animation: translation-float-in 0.5s ease-out both;
}
.gallery-translation--weakened {
color: var(--color-secondary);
}
.gallery-translation--blur {
filter: blur(4px);
opacity: 0.75;
transition:
filter 0.1s ease-in-out,
opacity 0.1s ease-in-out;
}
.gallery-translation--blur:hover {
filter: blur(0);
opacity: 1;
}
.gallery-translation--blockquote {
padding: 4px 0 4px 8px;
border-left: 4px solid var(--color-brand);
}
.gallery-translation--dashed-line {
text-decoration: underline dashed var(--color-brand);
text-underline-offset: 5px;
}
.gallery-translation--border {
padding: 2px 4px;
border: 1px solid var(--color-brand);
border-radius: 4px;
}
.gallery-translation--text-color {
color: oklch(0.693 0.17 162.48);
}
.gallery-translation--background {
padding: 2px 4px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
}
@keyframes translation-float-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,332 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, FileArchiveIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
Card,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import {
import_mcarchive_content,
install_mcarchive_content,
type McArchiveContentInstallRequest,
} from '@/helpers/instance'
import {
getMcArchiveModBySlug,
type McArchiveFile,
type McArchiveModVersion,
} from '@/helpers/mcarchive'
import { injectContentSelection } from '@/providers/content-selection'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const route = useRoute()
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const contentSelection = injectContentSelection()
const breadcrumbs = useBreadcrumbs()
const instanceSelector = ref<InstanceType<typeof BrowseInstanceSelector>>()
const project = ref<Awaited<ReturnType<typeof getMcArchiveModBySlug>> | null>(null)
const loading = ref(false)
const busyFileId = ref<string | null>(null)
const manualDownload = ref<{
request: McArchiveContentInstallRequest
fileName: string
pageUrl: string | null
expectedSha256: string | null
} | null>(null)
const messages = defineMessages({
loading: {
id: 'app.project.mcarchive.loading',
defaultMessage: 'Loading MCArchive project…',
},
versions: {
id: 'app.project.mcarchive.versions',
defaultMessage: 'Versions',
},
chooseInstance: {
id: 'app.browse.choose-instance',
defaultMessage: 'Choose instance',
},
install: {
id: 'app.project.mcarchive.install',
defaultMessage: 'Install',
},
installed: {
id: 'app.project.mcarchive.installed',
defaultMessage: 'Installed {fileName}',
},
manualTitle: {
id: 'app.project.mcarchive.manual.title',
defaultMessage: 'Manual download required',
},
manualDescription: {
id: 'app.project.mcarchive.manual.description',
defaultMessage:
'{fileName} has no verifiable direct download. Download it from the source page, then import the downloaded file.',
},
openSource: {
id: 'app.project.mcarchive.manual.open-source',
defaultMessage: 'Open source page',
},
importFile: {
id: 'app.project.mcarchive.manual.import-file',
defaultMessage: 'Import downloaded file',
},
pickFile: {
id: 'app.project.mcarchive.manual.pick-file',
defaultMessage: 'Choose downloaded archive',
},
archiveFilter: {
id: 'app.project.mcarchive.manual.archive-filter',
defaultMessage: 'Minecraft archives',
},
source: {
id: 'app.project.mcarchive.source',
defaultMessage: 'MCArchive source',
},
noFiles: {
id: 'app.project.mcarchive.no-files',
defaultMessage: 'No files are available for this release.',
},
sha256: {
id: 'app.project.mcarchive.sha256',
defaultMessage: 'SHA-256',
},
})
const selectedInstance = computed(() => contentSelection.targetInstance.value)
function createRequest(version: McArchiveModVersion, file: McArchiveFile) {
if (!project.value) throw new Error('MCArchive project is unavailable')
return {
projectId: project.value.uuid,
projectSlug: project.value.slug,
versionId: version.uuid,
fileId: file.uuid,
projectType: 'mod',
} satisfies McArchiveContentInstallRequest
}
async function install(version: McArchiveModVersion, file: McArchiveFile) {
if (!selectedInstance.value) {
await contentSelection.refreshInstances(
typeof route.query.i === 'string' ? route.query.i : undefined,
)
instanceSelector.value?.show()
return
}
busyFileId.value = file.uuid
try {
const request = createRequest(version, file)
const result = await install_mcarchive_content(selectedInstance.value.id, request)
if (result.state === 'manual_download') {
manualDownload.value = {
request,
fileName: result.fileName,
pageUrl: result.pageUrl,
expectedSha256: result.expectedSha256,
}
return
}
addNotification({
type: 'success',
title: formatMessage(messages.installed, { fileName: file.name }),
})
} catch (error) {
handleError(error)
} finally {
busyFileId.value = null
}
}
async function importDownloadedFile() {
if (!manualDownload.value || !selectedInstance.value) return
const path = await open({
multiple: false,
title: formatMessage(messages.pickFile),
filters: [{ name: formatMessage(messages.archiveFilter), extensions: ['jar', 'zip'] }],
})
if (!path || Array.isArray(path)) return
busyFileId.value = manualDownload.value.request.fileId
try {
const result = await import_mcarchive_content(
selectedInstance.value.id,
manualDownload.value.request,
path,
)
if (result.state === 'manual_download') {
manualDownload.value = {
request: manualDownload.value.request,
fileName: result.fileName,
pageUrl: result.pageUrl,
expectedSha256: result.expectedSha256,
}
return
}
addNotification({
type: 'success',
title: formatMessage(messages.installed, { fileName: manualDownload.value.fileName }),
})
manualDownload.value = null
} catch (error) {
handleError(error)
} finally {
busyFileId.value = null
}
}
async function selectInstance(instance: (typeof contentSelection.instances.value)[number]) {
contentSelection.setTarget(instance)
}
watch(
() => route.params.slug,
async (slug) => {
if (typeof slug !== 'string' || !slug) return
loading.value = true
project.value = null
manualDownload.value = null
try {
project.value = await getMcArchiveModBySlug(slug)
breadcrumbs.setName('Project', project.value.name)
breadcrumbs.setNameIcon('Project', project.value.icon_url ?? project.value.iconUrl ?? null)
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
},
{ immediate: true },
)
void contentSelection
.refreshInstances(typeof route.query.i === 'string' ? route.query.i : undefined)
.then(() => {
if (typeof route.query.i !== 'string' || contentSelection.targetInstance.value) return
const requested = contentSelection.instances.value.find(
(instance) => instance.id === route.query.i,
)
if (requested) contentSelection.setTarget(requested)
})
.catch(handleError)
</script>
<template>
<div v-if="loading" class="flex min-h-64 items-center justify-center gap-3 p-6 text-secondary">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="project" class="mx-auto flex w-full max-w-5xl flex-col gap-5 p-6">
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ project.name }}</h1>
<p v-if="project.summary" class="mb-0 mt-2 text-secondary">{{ project.summary }}</p>
</div>
<ButtonStyled size="standard" type="standard">
<button class="flex min-w-0 items-center gap-2" @click="instanceSelector?.show()">
<InstanceIcon
v-if="selectedInstance"
class="shrink-0"
size="1.25rem"
:icon-path="selectedInstance.icon_path"
:instance-id="selectedInstance.id"
:loader="selectedInstance.loader"
/>
<span class="max-w-48 truncate font-medium">
{{ selectedInstance?.name ?? formatMessage(messages.chooseInstance) }}
</span>
</button>
</ButtonStyled>
</div>
<p v-if="project.description" class="m-0 whitespace-pre-wrap text-sm text-secondary">
{{ project.description }}
</p>
</section>
<Card v-if="manualDownload" class="flex flex-col gap-3">
<div>
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.manualTitle) }}
</h2>
<p class="mb-0 mt-1 text-sm text-secondary">
{{ formatMessage(messages.manualDescription, { fileName: manualDownload.fileName }) }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled v-if="manualDownload.pageUrl" type="outlined">
<button @click="openUrl(manualDownload!.pageUrl!)">
<ExternalIcon />
{{ formatMessage(messages.openSource) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="busyFileId !== null" @click="importDownloadedFile">
<FileArchiveIcon />
{{ formatMessage(messages.importFile) }}
</button>
</ButtonStyled>
</div>
</Card>
<section class="flex flex-col gap-3">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.versions) }}
</h2>
<Card v-for="version in project.modVersions" :key="version.uuid" class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-contrast">{{ version.name }}</span>
<span class="text-sm text-secondary">
{{ version.gameVersions.map((gameVersion) => gameVersion.name).join(', ') }}
</span>
</div>
<div
v-if="version.files.length"
class="divide-y divide-surface-4 border-y border-surface-4"
>
<div
v-for="file in version.files"
:key="file.uuid"
class="flex min-w-0 items-center gap-3 py-3"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-contrast">{{ file.name }}</div>
<div v-if="file.sha256" class="truncate text-xs text-secondary">
{{ formatMessage(messages.sha256) }}: {{ file.sha256 }}
</div>
</div>
<ButtonStyled size="standard">
<button :disabled="busyFileId !== null" @click="install(version, file)">
<SpinnerIcon v-if="busyFileId === file.uuid" class="animate-spin" />
<DownloadIcon v-else />
{{ formatMessage(messages.install) }}
</button>
</ButtonStyled>
</div>
</div>
<p v-else class="m-0 text-sm text-secondary">{{ formatMessage(messages.noFiles) }}</p>
</Card>
</section>
</div>
<div v-else class="p-6">
<Card class="text-secondary">{{ formatMessage(messages.loading) }}</Card>
</div>
<BrowseInstanceSelector
ref="instanceSelector"
:instances="contentSelection.instances.value"
:selected-instance="selectedInstance"
:selected-count="0"
:install-current="async () => true"
:clear-current="() => {}"
@select="selectInstance"
/>
</template>

View File

@ -0,0 +1,257 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, FileArchiveIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
Card,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BrowseInstanceSelector from '@/components/browse/BrowseInstanceSelector.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import {
import_planet_minecraft_content,
install_planet_minecraft_content,
type PlanetMinecraftContentInstallRequest,
} from '@/helpers/instance'
import { getPlanetMinecraftProject, type PlanetMinecraftVersion } from '@/helpers/planet-minecraft'
import { injectContentSelection } from '@/providers/content-selection'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const route = useRoute()
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const contentSelection = injectContentSelection()
const breadcrumbs = useBreadcrumbs()
const instanceSelector = ref<InstanceType<typeof BrowseInstanceSelector>>()
const project = ref<Awaited<ReturnType<typeof getPlanetMinecraftProject>> | null>(null)
const loading = ref(false)
const busyVersionId = ref<string | null>(null)
const manualDownload = ref<{
request: PlanetMinecraftContentInstallRequest
pageUrl: string
fileName: string | null
} | null>(null)
const messages = defineMessages({
loading: {
id: 'app.project.planet-minecraft.loading',
defaultMessage: 'Loading Planet Minecraft project…',
},
chooseInstance: { id: 'app.browse.choose-instance', defaultMessage: 'Choose instance' },
versions: { id: 'app.project.planet-minecraft.versions', defaultMessage: 'Downloads' },
install: { id: 'app.project.planet-minecraft.install', defaultMessage: 'Install' },
manualTitle: {
id: 'app.project.planet-minecraft.manual.title',
defaultMessage: 'Manual download required',
},
manualDescription: {
id: 'app.project.planet-minecraft.manual.description',
defaultMessage: '{fileName} must be downloaded from Planet Minecraft, then imported here.',
},
unknownFile: {
id: 'app.project.planet-minecraft.unknown-file',
defaultMessage: 'Downloaded file',
},
openSource: {
id: 'app.project.planet-minecraft.manual.open-source',
defaultMessage: 'Open source page',
},
importFile: {
id: 'app.project.planet-minecraft.manual.import-file',
defaultMessage: 'Import downloaded file',
},
pickFile: {
id: 'app.project.planet-minecraft.manual.pick-file',
defaultMessage: 'Choose downloaded archive',
},
archiveFilter: {
id: 'app.project.planet-minecraft.manual.archive-filter',
defaultMessage: 'Minecraft archives',
},
installed: {
id: 'app.project.planet-minecraft.installed',
defaultMessage: 'Installed {fileName}',
},
})
const selectedInstance = computed(() => contentSelection.targetInstance.value)
function createRequest(version: PlanetMinecraftVersion): PlanetMinecraftContentInstallRequest {
if (!project.value) throw new Error('Planet Minecraft project is unavailable')
return { projectId: project.value.id, versionId: version.id, projectType: 'mod' }
}
async function install(version: PlanetMinecraftVersion) {
if (!selectedInstance.value) {
await contentSelection.refreshInstances(
typeof route.query.i === 'string' ? route.query.i : undefined,
)
instanceSelector.value?.show()
return
}
busyVersionId.value = version.id
try {
const request = createRequest(version)
const result = await install_planet_minecraft_content(selectedInstance.value.id, request)
if (result.state === 'manual_download') {
manualDownload.value = { request, pageUrl: result.pageUrl, fileName: result.fileName }
return
}
addNotification({
type: 'success',
title: formatMessage(messages.installed, {
fileName: version.download.fileName ?? version.name,
}),
})
} catch (error) {
handleError(error)
} finally {
busyVersionId.value = null
}
}
async function importDownloadedFile() {
if (!manualDownload.value || !selectedInstance.value) return
const path = await open({
multiple: false,
title: formatMessage(messages.pickFile),
filters: [{ name: formatMessage(messages.archiveFilter), extensions: ['jar', 'zip'] }],
})
if (!path || Array.isArray(path)) return
busyVersionId.value = manualDownload.value.request.versionId
try {
const result = await import_planet_minecraft_content(
selectedInstance.value.id,
manualDownload.value.request,
path,
)
const fileName =
manualDownload.value.fileName ?? (result.state === 'installed' ? result.relativePath : '')
addNotification({ type: 'success', title: formatMessage(messages.installed, { fileName }) })
manualDownload.value = null
} catch (error) {
handleError(error)
} finally {
busyVersionId.value = null
}
}
watch(
() => route.params.id,
async (id) => {
if (typeof id !== 'string' || !id) return
loading.value = true
try {
project.value = await getPlanetMinecraftProject(id)
breadcrumbs.setName('Project', project.value.title)
breadcrumbs.setNameIcon('Project', project.value.icon_url ?? project.value.iconUrl ?? null)
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
},
{ immediate: true },
)
void contentSelection
.refreshInstances(typeof route.query.i === 'string' ? route.query.i : undefined)
.then(() => {
if (typeof route.query.i !== 'string' || contentSelection.targetInstance.value) return
const requested = contentSelection.instances.value.find(
(instance) => instance.id === route.query.i,
)
if (requested) contentSelection.setTarget(requested)
})
.catch(handleError)
</script>
<template>
<div v-if="loading" class="flex min-h-64 items-center justify-center gap-3 p-6 text-secondary">
<SpinnerIcon class="animate-spin" /> {{ formatMessage(messages.loading) }}
</div>
<div v-else-if="project" class="mx-auto flex w-full max-w-5xl flex-col gap-5 p-6">
<section class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ project.title }}</h1>
<p v-if="project.summary" class="mb-0 mt-2 text-secondary">{{ project.summary }}</p>
</div>
<ButtonStyled
><button class="flex items-center gap-2" @click="instanceSelector?.show()">
<InstanceIcon
v-if="selectedInstance"
size="1.25rem"
:icon-path="selectedInstance.icon_path"
:instance-id="selectedInstance.id"
:loader="selectedInstance.loader"
/>
{{ selectedInstance?.name ?? formatMessage(messages.chooseInstance) }}
</button></ButtonStyled
>
</section>
<Card v-if="manualDownload" class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.manualTitle) }}
</h2>
<p class="mb-0 mt-1 text-sm text-secondary">
{{
formatMessage(messages.manualDescription, {
fileName: manualDownload.fileName ?? formatMessage(messages.unknownFile),
})
}}
</p>
</div>
<div class="flex gap-2">
<ButtonStyled type="outlined"
><button @click="openUrl(manualDownload!.pageUrl)">
<ExternalIcon /> {{ formatMessage(messages.openSource) }}
</button></ButtonStyled
>
<ButtonStyled
><button :disabled="busyVersionId !== null" @click="importDownloadedFile">
<FileArchiveIcon /> {{ formatMessage(messages.importFile) }}
</button></ButtonStyled
>
</div>
</Card>
<section class="flex flex-col gap-3">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.versions) }}
</h2>
<Card
v-for="version in project.versions"
:key="version.id"
class="flex flex-wrap items-center justify-between gap-3"
>
<div class="min-w-0">
<div class="font-semibold text-contrast">{{ version.name }}</div>
<div class="text-sm text-secondary">{{ version.gameVersions.join(', ') }}</div>
</div>
<ButtonStyled
><button :disabled="busyVersionId !== null" @click="install(version)">
<SpinnerIcon v-if="busyVersionId === version.id" class="animate-spin" /><DownloadIcon
v-else
/>
{{ formatMessage(messages.install) }}
</button></ButtonStyled
>
</Card>
</section>
</div>
<BrowseInstanceSelector
ref="instanceSelector"
:instances="contentSelection.instances.value"
:selected-instance="selectedInstance"
:selected-count="0"
:install-current="async () => true"
:clear-current="() => {}"
@select="contentSelection.setTarget"
/>
</template>

View File

@ -0,0 +1,34 @@
<template>
<FloatingActionBar
v-if="snapshot"
:shown="true"
aria-label="Return to instance upgrade"
hide-when-modal-open
>
<ButtonStyled color="brand" size="large">
<button @click="returnToUpgrade">
<ArrowLeftIcon aria-hidden="true" />
{{ formatMessage(messages.returnAction) }}
</button>
</ButtonStyled>
</FloatingActionBar>
</template>
<script setup lang="ts">
import { ArrowLeftIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, FloatingActionBar, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { peekUpgradeFlow } from '@/helpers/upgrade-return-state'
const messages = defineMessages({
returnAction: { id: 'instance.upgrade.return', defaultMessage: 'Return to instance upgrade' },
})
const router = useRouter()
const { formatMessage } = useVIntl()
const snapshot = computed(() => peekUpgradeFlow())
async function returnToUpgrade() {
if (snapshot.value) await router.push(snapshot.value.returnFullPath)
}
</script>

View File

@ -0,0 +1,219 @@
<template>
<div class="flex flex-col">
<router-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</router-link>
<VersionPage
v-if="version"
:version="version"
:enrichment="enrichment"
:enrichment-loading="enrichmentLoading"
:members="members"
:dependency-link-creator="createDependencyLink"
>
<template #headerActions>
<ButtonStyled color="brand">
<button
:disabled="installing || (installed && installedVersion === version.id)"
@click="() => version && install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installedVersion !== version.id" />
<CheckIcon v-else />
{{
installing
? formatMessage(messages.installing)
: installed && installedVersion === version.id
? formatMessage(commonMessages.installedLabel)
: installed
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(commonMessages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" circular>
<OverflowMenu
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
external: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
external: true,
},
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template #supplementaryResourceActions="{ file }">
<ButtonStyled>
<a :href="file.url" :download="file.filename" target="_blank">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadInBrowser) }}
</a>
</ButtonStyled>
</template>
</VersionPage>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronLeftIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
type DependencyContext,
OverflowMenu,
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const { formatMessage } = useVIntl()
const messages = defineMessages({
allVersions: {
id: 'app.project.version.all-versions',
defaultMessage: 'All versions',
},
installing: {
id: 'app.project.version.installing',
defaultMessage: 'Installing',
},
downloadInBrowser: {
id: 'app.project.version.download-in-browser',
defaultMessage: 'Download in browser',
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps<{
project: Labrinth.Projects.v2.Project
versions: Labrinth.Versions.v3.Version[]
members: Labrinth.Projects.v3.TeamMember[]
install: (version: string | null) => void
installed: boolean
installing: boolean
installedVersion: string
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
function buildProjectHref(path: string): string {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) {
if (v != null) params.append(key, v)
}
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
function createDependencyLink(context: DependencyContext): string | undefined {
if (context.version) {
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
}
if (context.project) {
return buildProjectHref(`/project/${context.project.id}`)
}
return undefined
}
async function refreshEnrichment() {
if (!version.value) return
const projectIds = new Set<string>()
const versionIds = new Set<string>()
for (const dependency of version.value.dependencies ?? []) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
if (projectIds.size === 0 && versionIds.size === 0) {
enrichment.value = { projects: [], versions: [] }
return
}
enrichmentLoading.value = true
try {
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
for (const dependencyVersion of versionResults ?? []) {
if (dependencyVersion.project_id) {
projectIds.add(dependencyVersion.project_id)
}
}
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
enrichment.value = {
projects: projectResults ?? [],
versions: versionResults ?? [],
}
} finally {
enrichmentLoading.value = false
}
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
)
await refreshEnrichment()
</script>

View File

@ -0,0 +1,221 @@
<template>
<div>
<ProjectPageVersions
:loaders="loaders"
:game-versions="gameVersions"
:versions="versions"
:project="project"
:show-environment-column="themeStore.featureFlags.show_version_environment_column"
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled
circular
type="transparent"
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
>
<button
v-tooltip="
!installed
? formatMessage(commonMessages.installButton)
: version.id !== installedVersion
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(messages.alreadyInstalled)
"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installed && version.id !== installedVersion" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<ButtonStyled v-if="serverCapable && startServer" circular type="transparent">
<button
v-tooltip="formatMessage(messages.startServer)"
@click.stop="() => startServer(version)"
>
<ServerIcon />
</button>
</ButtonStyled>
-->
<ButtonStyled circular type="transparent">
<OverflowMenu
v-if="false"
:options="[
{
id: 'install-elsewhere',
action: () => {},
shown: false && !!instance,
color: 'primary',
hoverFilled: true,
},
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
},
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #install-elsewhere>
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.addToAnotherInstance) }}
</template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
</OverflowMenu>
<a
v-else
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
<ExternalIcon />
</a>
</ButtonStyled>
</template>
</ProjectPageVersions>
</div>
</template>
<script setup>
import {
CheckIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ServerIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useTheming } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
alreadyInstalled: {
id: 'app.project.versions.already-installed',
defaultMessage: 'Already installed',
},
addToAnotherInstance: {
id: 'app.project.versions.add-to-another-instance',
defaultMessage: 'Add to another instance',
},
startServer: {
id: 'app.project.versions.start-server',
defaultMessage: 'Create server',
},
})
const props = defineProps({
project: {
type: Object,
default: () => {},
},
versions: {
type: Array,
required: true,
},
install: {
type: Function,
required: true,
},
installed: {
type: Boolean,
default: null,
},
installing: {
type: Boolean,
default: false,
},
instance: {
type: Object,
default: null,
},
installedVersion: {
type: String,
default: null,
},
startServer: {
type: Function,
default: null,
},
})
const serverCapable = computed(
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
)
const { handleError } = injectNotificationManager()
const route = useRoute()
function buildProjectHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
])
</script>
<style scoped lang="scss">
.table-row {
grid-template-columns: min-content 1fr 1fr 1.5fr;
}
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-raised-bg);
}
.select {
width: 100% !important;
max-width: 20rem;
}
.version-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-wrap: wrap;
.version-badge {
display: flex;
flex-wrap: wrap;
}
}
.filter-checkbox {
:deep(.checkbox) {
border: none;
}
}
</style>

View File

@ -0,0 +1,8 @@
import CurseForge from './CurseForge.vue'
import Description from './Description.vue'
import Gallery from './Gallery.vue'
import Index from './Index.vue'
import Version from './Version.vue'
import Versions from './Versions.vue'
export { CurseForge, Description, Gallery, Index, Version, Versions }