Files
Starlight_Launcher/apps/app-frontend/src/pages/instance/upgrade/Customize.vue
T
2026-09-12 23:29:28 +08:00

915 lines
32 KiB
Vue

<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 Starlight 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 Starlight 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:
"Starlight 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>