feat: content management changes (#6104)

* feat: change modpack updating flow

* fix: pending install state loss

* fix: mods.vue perf problems

* chore: todo doc

* draft: try preload/fix suspense

* fix: lint
This commit is contained in:
Calum H.
2026-05-20 17:07:35 +00:00
committed by GitHub
parent 079a10bba9
commit c3fe7b4232
19 changed files with 1111 additions and 277 deletions
+18 -12
View File
@@ -875,6 +875,7 @@ impl CachedEntry {
.fetch_all(pool)
.await?;
let now = Utc::now().timestamp();
for row in query {
let parsed_data = if let Some(data) = row.data.clone() {
Some(Self::deserialize_cache_value(type_, data, &row.id)?)
@@ -882,7 +883,7 @@ impl CachedEntry {
None
};
if row.expires <= Utc::now().timestamp() {
if row.expires <= now {
if cache_behaviour == CacheBehaviour::MustRevalidate {
continue;
} else {
@@ -890,6 +891,19 @@ impl CachedEntry {
}
}
let row_id = row.id.clone();
let row_alias = row.alias.clone();
let remove_matching_key = |x: &&str| {
x != &&*row_id
&& !row_alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true) {
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
};
if let Some(data) = parsed_data {
if data.get_type() != type_ {
return Err(crate::ErrorKind::OtherError(format!(
@@ -901,17 +915,7 @@ impl CachedEntry {
.as_error());
}
remaining_keys.retain(|x| {
x != &&*row.id
&& !row.alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true)
{
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
});
remaining_keys.retain(remove_matching_key);
return_vals.push(Self {
id: row.id,
@@ -920,6 +924,8 @@ impl CachedEntry {
data: Some(data),
expires: row.expires,
});
} else {
remaining_keys.retain(remove_matching_key);
}
}
}
+35 -58
View File
@@ -3,13 +3,12 @@
//! ## Data Flow
//!
//! 1. Frontend calls `get_content_items(profile_path)`
//! 2. Backend fetches all installed files via `Profile::get_projects()`
//! 3. If profile is linked to a modpack:
//! 2. If profile is linked to a modpack:
//! - Fetch modpack file hashes from cache (populated during installation)
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
//! - Filter out files that belong to the modpack
//! 4. For remaining files, fetch project/version/owner metadata in parallel
//! 5. Return sorted `ContentItem` list
//! - Filter out files that belong to the modpack before update lookup
//! 3. For remaining files, fetch project/version/owner metadata in parallel
//! 4. Return sorted `ContentItem` list
//!
//! ## Caching
//!
@@ -226,12 +225,8 @@ pub async fn get_linked_modpack_info(
};
// Check for updates
let (has_update, update_version_id, update_version) = check_modpack_update(
profile,
&linked_data.version_id,
&version,
all_versions,
);
let (has_update, update_version_id, update_version) =
check_modpack_update(&linked_data.version_id, &version, all_versions);
Ok(Some(LinkedModpackInfo {
project,
@@ -243,10 +238,9 @@ pub async fn get_linked_modpack_info(
}))
}
/// Check if a newer compatible version exists for the linked modpack.
/// Check if a newer version exists for the linked modpack.
/// Returns (has_update, update_version_id, update_version).
fn check_modpack_update(
profile: &Profile,
installed_version_id: &str,
installed_version: &Version,
all_versions: Option<Vec<Version>>,
@@ -255,44 +249,19 @@ fn check_modpack_update(
return (false, None, None);
};
// Get the loader as a string for comparison
let loader_str = profile.loader.as_str().to_lowercase();
let game_version = &profile.game_version;
// Filter to compatible versions
let mut compatible_versions: Vec<&Version> = versions
let mut newer_versions: Vec<&Version> = versions
.iter()
.filter(|v| {
// Must support the profile's game version
let supports_game = v.game_versions.contains(game_version);
// Must support the profile's loader
// The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
// but if mrpack_loaders is missing, loaders may be just ["mrpack"].
// In that case we can't filter by loader, so accept the version.
let real_loaders: Vec<_> = v
.loaders
.iter()
.filter(|l| l.to_lowercase() != "mrpack")
.collect();
let supports_loader = real_loaders.is_empty()
|| real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
supports_game && supports_loader
v.id != installed_version_id
&& v.date_published > installed_version.date_published
})
.collect();
// Sort by date_published descending (newest first)
compatible_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
// Find the newest compatible version
if let Some(newest) = compatible_versions.first() {
// Check if the newest version is different and newer than installed
if newest.id != installed_version_id
&& newest.date_published > installed_version.date_published
{
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
if let Some(newest) = newer_versions.first() {
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
(false, None, None)
@@ -306,10 +275,6 @@ pub async fn get_content_items(
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
if linked_data.version_id.is_empty() {
None
@@ -350,23 +315,35 @@ pub async fn get_content_items(
None
};
let user_files: Vec<(String, ProfileFile)> = all_files
.into_iter()
.filter(|(_, file)| {
modpack_ids
.as_ref()
.is_none_or(|ids| !ids.is_modpack_file(file))
})
.collect();
let user_files: Vec<(String, ProfileFile)> = if let Some(ids) = &modpack_ids
{
let filtered_files = profile
.get_projects_excluding_modpack_files(
&ids.hashes,
&ids.project_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
filtered_files.into_iter().collect()
} else {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
all_files.into_iter().collect()
};
profile_files_to_content_items(
let content_items = profile_files_to_content_items(
&profile.path,
&user_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
.await?;
Ok(content_items)
}
/// Pre-fetched metadata for projects, versions, teams, and organizations.
+133 -33
View File
@@ -12,7 +12,7 @@ use dashmap::DashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::convert::TryInto;
use std::path::Path;
@@ -939,41 +939,144 @@ impl Profile {
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
self.get_projects_inner(
cache_behaviour,
pool,
fetch_semaphore,
None,
None,
)
.await
}
pub async fn get_projects_excluding_modpack_files(
&self,
excluded_hashes: &HashSet<String>,
excluded_project_ids: &HashSet<String>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
self.get_projects_inner(
cache_behaviour,
pool,
fetch_semaphore,
Some(excluded_hashes),
Some(excluded_project_ids),
)
.await
}
async fn get_projects_inner(
&self,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
excluded_hashes: Option<&HashSet<String>>,
excluded_project_ids: Option<&HashSet<String>>,
) -> crate::Result<DashMap<String, ProfileFile>> {
let (keys, file_hashes) =
self.scan_and_hash(pool, fetch_semaphore).await?;
let file_updates = file_hashes
.iter()
.map(|x| Self::get_cache_key(x, self))
let excluded_hashes = excluded_hashes.filter(|ids| !ids.is_empty());
let excluded_project_ids =
excluded_project_ids.filter(|ids| !ids.is_empty());
let file_hashes = file_hashes
.into_iter()
.filter(|hash| {
excluded_hashes
.is_none_or(|excluded| !excluded.contains(&hash.hash))
})
.collect::<Vec<_>>();
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let (file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
)?;
let (file_hashes, file_info_by_hash, file_updates) =
if let Some(excluded_project_ids) = excluded_project_ids {
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let mut keys_by_path: std::collections::HashMap<
String,
InitialScanFile,
> = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
let file_info_by_hash: HashMap<String, CachedFile> = file_info
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_info_by_hash: std::collections::HashMap<String, CachedFile> =
file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
let file_hashes = file_hashes
.into_iter()
.filter(|hash| {
file_info_by_hash.get(&hash.hash).is_none_or(|file| {
!excluded_project_ids.contains(&file.project_id)
})
})
.collect::<Vec<_>>();
let file_updates = file_hashes
.iter()
.filter(|x| file_info_by_hash.contains_key(&x.hash))
.map(|x| Self::get_cache_key(x, self))
.collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let file_updates = CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
(file_hashes, file_info_by_hash, file_updates)
} else {
let file_updates = file_hashes
.iter()
.map(|x| Self::get_cache_key(x, self))
.collect::<Vec<_>>();
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let (file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
)?;
let file_info_by_hash: HashMap<String, CachedFile> = file_info
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
(file_hashes, file_info_by_hash, file_updates)
};
let mut keys_by_path: HashMap<String, InitialScanFile> =
keys.into_iter().map(|k| (k.path.clone(), k)).collect();
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
for update in file_updates {
updates_by_hash
.entry(update.hash)
.or_default()
.push(update.update_version_id);
}
let files = DashMap::new();
@@ -989,11 +1092,8 @@ impl Profile {
);
let update_version_id = if let Some(metadata) = &file {
let update_ids: Vec<String> = file_updates
.iter()
.filter(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
.collect();
let update_ids =
updates_by_hash.remove(&hash.hash).unwrap_or_default();
if !update_ids.contains(&metadata.version_id) {
update_ids.into_iter().next()
@@ -32,7 +32,7 @@
/>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-16">
<div class="flex-1 overflow-y-auto px-4" :class="isModpack ? 'pb-4' : 'pb-16'">
<div v-if="loading" class="flex flex-col items-center justify-center h-full gap-2">
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
<span class="text-sm text-secondary">{{
@@ -76,11 +76,11 @@
class="rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
:class="[
getBadgeClasses(version),
isVersionCompatible(version) ? 'px-2.5 py-0.5' : 'p-1',
shouldShowIncompatibleBadge(version) ? 'p-1' : 'px-2.5 py-0.5',
]"
>
<CircleAlertIcon
v-if="!isVersionCompatible(version)"
v-if="shouldShowIncompatibleBadge(version)"
v-tooltip="formatMessage(messages.incompatibleBadge)"
class="size-4"
/>
@@ -99,6 +99,7 @@
</div>
<div
v-if="!isModpack"
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
>
<div class="pointer-events-auto">
@@ -197,13 +198,16 @@
<div
class="w-full flex flex-row items-center gap-4 p-4 border-solid border-x-0 border-b-0 border-t border-surface-4"
>
<div class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto">
<div
v-if="showUpdateWarning"
class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto"
>
<TriangleAlertIcon class="size-6 shrink-0" />
<span>{{
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
}}</span>
</div>
<div class="flex flex-row gap-2 shrink-0">
<div class="flex flex-row gap-2 shrink-0 ml-auto">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
@@ -233,6 +237,21 @@
</div>
</div>
</NewModal>
<ConfirmModal
ref="incompatibleUpdateModal"
:title="formatMessage(messages.incompatibleUpdateHeader)"
:description="
formatMessage(messages.incompatibleUpdateDescription, {
version: pendingIncompatibleUpdate?.version.version_number ?? '...',
})
"
:proceed-icon="DownloadIcon"
:proceed-label="formatMessage(messages.updateAnywayButton)"
:danger="false"
:markdown="false"
@proceed="confirmIncompatibleUpdate"
/>
</template>
<script setup lang="ts">
@@ -255,11 +274,16 @@ import { computed, ref, watch } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import ConfirmModal from '#ui/components/modal/ConfirmModal.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import {
versionChangesGameVersion,
versionMatchesCompatibilityTarget,
} from '#ui/utils/version-compatibility'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentUpdaterModal')
@@ -338,6 +362,19 @@ const messages = defineMessages({
id: 'instances.updater-modal.loading-changelog',
defaultMessage: 'Loading changelog...',
},
incompatibleUpdateHeader: {
id: 'instances.updater-modal.incompatible-update.header',
defaultMessage: 'Update to incompatible version?',
},
incompatibleUpdateDescription: {
id: 'instances.updater-modal.incompatible-update.description',
defaultMessage:
'{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly.',
},
updateAnywayButton: {
id: 'instances.updater-modal.incompatible-update.proceed',
defaultMessage: 'Update anyway',
},
})
const props = withDefaults(
@@ -378,10 +415,15 @@ const emit = defineEmits<{
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const incompatibleUpdateModal = ref<InstanceType<typeof ConfirmModal>>()
const searchQuery = ref('')
const hideIncompatibleState = ref(true)
const switchMode = ref(false)
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const pendingIncompatibleUpdate = ref<{
version: Labrinth.Versions.v2.Version
event: MouseEvent
} | null>(null)
// Store the initial version ID to select when versions become available
const pendingInitialVersionId = ref<string | undefined>(undefined)
@@ -422,15 +464,13 @@ watch(
{ deep: true },
)
const NON_MOD_PROJECT_TYPES = new Set(['shader', 'shaderpack', 'resourcepack', 'datapack'])
function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
const hasGameVersion = version.game_versions.includes(props.currentGameVersion)
const skipLoaderCheck = props.projectType != null && NON_MOD_PROJECT_TYPES.has(props.projectType)
const hasLoader =
skipLoaderCheck ||
version.loaders.some((loader) => loader.toLowerCase() === props.currentLoader.toLowerCase())
const compatible = hasGameVersion && hasLoader
const compatible = versionMatchesCompatibilityTarget(version, {
gameVersion: props.currentGameVersion,
loader: props.currentLoader,
projectType: props.projectType,
})
if (!compatible) {
debug('isVersionCompatible: INCOMPATIBLE', {
versionId: version.id,
@@ -440,15 +480,13 @@ function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
currentLoader: props.currentLoader,
currentGameVersion: props.currentGameVersion,
projectType: props.projectType,
hasGameVersion,
hasLoader,
skipLoaderCheck,
})
}
return compatible
}
const currentVersion = computed(() => props.versions.find((v) => v.id === props.currentVersionId))
const showUpdateWarning = computed(() => !isModpack.value)
const isDowngrade = computed(() => {
if (!selectedVersion.value || !currentVersion.value) return false
@@ -468,8 +506,13 @@ const filteredVersions = computed(() => {
}
const beforeFilterCount = versions.length
if (hideIncompatibleState.value) {
versions = versions.filter(isVersionCompatible)
if (!isModpack.value && hideIncompatibleState.value) {
versions = versions.filter(
(version) =>
version.id === props.currentVersionId ||
version.id === selectedVersion.value?.id ||
isVersionCompatible(version),
)
}
debug('filteredVersions computed', {
@@ -478,18 +521,23 @@ const filteredVersions = computed(() => {
afterCompatibilityFilter: versions.length,
hiddenByCompatibility: beforeFilterCount - versions.length,
hideIncompatible: hideIncompatibleState.value,
filteringCompatibility: !isModpack.value && hideIncompatibleState.value,
})
return versions
})
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
return version.id === props.currentVersionId || !isVersionCompatible(version)
return version.id === props.currentVersionId || shouldShowIncompatibleBadge(version)
}
function shouldShowIncompatibleBadge(version: Labrinth.Versions.v2.Version): boolean {
return version.id !== props.currentVersionId && !isModpack.value && !isVersionCompatible(version)
}
function getBadgeLabel(version: Labrinth.Versions.v2.Version): string {
if (version.id === props.currentVersionId) return formatMessage(messages.currentBadge)
if (!isVersionCompatible(version)) return formatMessage(messages.incompatibleBadge)
if (shouldShowIncompatibleBadge(version)) return formatMessage(messages.incompatibleBadge)
return ''
}
@@ -499,8 +547,7 @@ function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
return 'bg-surface-4 border-surface-5 text-primary'
}
// Incompatible badge (takes precedence over version type)
if (!isVersionCompatible(version)) {
if (shouldShowIncompatibleBadge(version)) {
return 'bg-highlight-orange border-brand-orange text-brand-orange'
}
@@ -568,7 +615,61 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
function handleUpdate(event: MouseEvent) {
if (selectedVersion.value) {
emit('update', selectedVersion.value, event)
const changesGameVersion = versionChangesGameVersion(
selectedVersion.value,
props.currentGameVersion,
)
const shouldShowParentWarning =
isModpack.value && !event.shiftKey && (changesGameVersion || isDowngrade.value)
if (
isModpack.value &&
!event.shiftKey &&
!isVersionCompatible(selectedVersion.value) &&
!changesGameVersion
) {
pendingIncompatibleUpdate.value = {
version: selectedVersion.value,
event,
}
incompatibleUpdateModal.value?.show()
return
}
emitUpdate(selectedVersion.value, event, {
hide: !shouldShowParentWarning,
})
}
}
function confirmIncompatibleUpdate() {
const pendingUpdate = pendingIncompatibleUpdate.value
pendingIncompatibleUpdate.value = null
if (pendingUpdate) {
const current = currentVersion.value
const isPendingDowngrade = current
? new Date(pendingUpdate.version.date_published) < new Date(current.date_published)
: false
const changesGameVersion = versionChangesGameVersion(
pendingUpdate.version,
props.currentGameVersion,
)
const shouldShowParentWarning =
isModpack.value && !pendingUpdate.event.shiftKey && (changesGameVersion || isPendingDowngrade)
emitUpdate(pendingUpdate.version, pendingUpdate.event, {
hide: !shouldShowParentWarning,
})
}
}
function emitUpdate(
version: Labrinth.Versions.v2.Version,
event: MouseEvent,
options: { hide?: boolean } = {},
) {
emit('update', version, event)
if (options.hide ?? true) {
hide()
}
}
@@ -580,7 +681,8 @@ function handleCancel() {
function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
searchQuery.value = ''
hideIncompatibleState.value = true
hideIncompatibleState.value = !isModpack.value
pendingIncompatibleUpdate.value = null
switchMode.value = options?.switchMode ?? false
debug('show() called', {
@@ -36,6 +36,7 @@ interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
busy?: boolean
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
}
@@ -44,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
busy: false,
getOverflowOptions: undefined,
switchVersion: undefined,
})
@@ -247,12 +249,13 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
installing: item.installing === true,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
disabled: disabledIds.value.has(item.file_name),
disabled: props.busy || disabledIds.value.has(item.file_name) || item.installing === true,
overflowOptions: [
...(props.switchVersion
? [
@@ -283,17 +286,20 @@ function getTypeIcon(type: string) {
}
function handleEnabledChange(fileName: string, value: boolean) {
if (props.busy) return
const item = items.value.find((i) => i.file_name === fileName)
if (!item) return
emit('update:enabled', item, value)
}
function bulkEnable() {
if (props.busy) return
emit('bulk:enable', [...selectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
if (props.busy) return
emit('bulk:disable', [...selectedItems.value])
selectedIds.value = []
}
@@ -361,7 +367,15 @@ function updateItem(fileName: string, updates: Partial<ContentItem> & { disabled
}
}
defineExpose({ show, showLoading, hide, getState, restore, updateItem })
function setItems(contentItems: ContentItem[]) {
const contentFileNames = new Set(contentItems.map((item) => item.file_name))
items.value = contentItems
selectedIds.value = selectedIds.value.filter((id) => contentFileNames.has(id))
disabledIds.value = new Set([...disabledIds.value].filter((id) => contentFileNames.has(id)))
loading.value = false
}
defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems })
</script>
<template>
@@ -544,6 +558,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
<ContentSelectionBar
v-if="props.enableToggle"
:selected-items="selectedItems"
:is-bulk-operating="props.busy"
style="--left-bar-width: 0px; --right-bar-width: 0px"
@clear="selectedIds = []"
@enable="bulkEnable"
@@ -26,6 +26,7 @@ import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
@@ -120,22 +121,31 @@ const isLocalFile = computed(() => {
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
pendingUpdateVersion.value = version
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
const currentVersion = form.updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isUpdateDowngrade.value = currentVersion
? new Date(version.date_published) < new Date(currentVersion.date_published)
: false
if (event?.shiftKey) {
const shouldShowWarning =
isUpdateDowngrade.value ||
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
if (event?.shiftKey || !shouldShowWarning) {
handleModpackUpdateConfirm()
} else {
modpackUpdateModal.value?.show()
return
}
modpackUpdateModal.value?.show()
}
function handleModpackUpdateConfirm() {
if (pendingUpdateVersion.value) {
const version = pendingUpdateVersion.value
if (version) {
contentUpdaterModal.value?.hide()
form.cancelEditing()
form.handleUpdaterConfirm(pendingUpdateVersion.value)
ctx.closeSettings?.()
form.handleUpdaterConfirm(version)
pendingUpdateVersion.value = null
}
}
@@ -64,6 +64,7 @@ export interface InstallationSettingsContext {
reinstalling?: Ref<boolean>
afterSave?: () => Promise<void>
closeSettings?: () => void
lockPlatform?: boolean
hideLoaderVersion?: boolean
@@ -354,6 +354,7 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
}
provideInstallationSettings({
closeSettings: serverSettings.closeModal,
onGameVersionHover: handleGameVersionHover,
loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => {
@@ -2,6 +2,7 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -21,6 +22,7 @@ import {
readPendingServerContentInstalls,
removePendingServerContentInstall,
} from '#ui/utils/server-content-installing'
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
import {
flushStoredServerAddonInstallQueue,
@@ -160,6 +162,44 @@ const projectQuery = useQuery({
enabled: computed(() => !!modpackProjectId.value),
})
function getVersionTime(version: Labrinth.Versions.v2.Version) {
return new Date(version.date_published).getTime()
}
function sortVersionsByPublishedDate(versions: Labrinth.Versions.v2.Version[]) {
return [...versions].sort((a, b) => getVersionTime(b) - getVersionTime(a))
}
const currentModpackVersionId = computed(() => {
const spec = contentQuery.data.value?.modpack?.spec
return spec?.platform === 'modrinth' ? spec.version_id : null
})
const newestModpackUpdateVersion = computed(() => {
const currentVersionId = currentModpackVersionId.value
if (!currentVersionId) return null
const versions = sortVersionsByPublishedDate(modpackVersionsQuery.data.value ?? [])
const currentVersion = versions.find((version) => version.id === currentVersionId)
const installedPublishedAt = contentQuery.data.value?.modpack?.date_published
const storedCurrentTime = installedPublishedAt
? new Date(installedPublishedAt).getTime()
: Number.NaN
const currentVersionTime = Number.isNaN(storedCurrentTime)
? currentVersion
? getVersionTime(currentVersion)
: Number.NaN
: storedCurrentTime
return (
versions.find((version) => {
if (version.id === currentVersionId) return false
if (Number.isNaN(currentVersionTime)) return true
return getVersionTime(version) > currentVersionTime
}) ?? null
)
})
const modpack = computed<ContentModpackData | null>(() => {
const mp = contentQuery.data.value?.modpack
if (!mp) return null
@@ -207,7 +247,7 @@ const modpack = computed<ContentModpackData | null>(() => {
project_type: 'modpack',
header: 'categories',
})) as ContentModpackCardCategory[],
hasUpdate: !!mp.has_update,
hasUpdate: !!mp.has_update || !!newestModpackUpdateVersion.value,
}
})
@@ -237,6 +277,14 @@ const lastStableContentKeys = ref<Set<string>>(new Set())
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
const contentInstallAddedKeys = ref<Set<string>>(new Set())
const isFlushingStoredServerInstalls = ref(false)
const { pause: pausePendingInstallPoll, resume: resumePendingInstallPoll } = useIntervalFn(
() => {
if (pendingServerContentInstalls.value.length === 0 || contentQuery.isFetching.value) return
void contentQuery.refetch()
},
5000,
{ immediate: false },
)
function syncPendingServerContentInstalls() {
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
@@ -262,6 +310,27 @@ function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) {
return keys
}
function addonMatchesPendingInstall(
addon: Archon.Content.v1.Addon,
pendingInstall: PendingServerContentInstall,
) {
return (
addon.project_id === pendingInstall.projectId ||
addon.version?.id === pendingInstall.versionId ||
(!!pendingInstall.fileName && addon.filename === pendingInstall.fileName)
)
}
function removeResolvedPendingServerContentInstalls(addons: Archon.Content.v1.Addon[]) {
if (addons.length === 0 || pendingServerContentInstalls.value.length === 0) return
for (const pendingInstall of pendingServerContentInstalls.value) {
if (addons.some((addon) => addonMatchesPendingInstall(addon, pendingInstall))) {
removePendingServerContentInstall(serverId, worldId.value, pendingInstall.projectId)
}
}
}
function syncContentInstallKeys(
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
) {
@@ -373,17 +442,32 @@ const rawContentItems = computed<ContentItem[]>(() => {
const pendingInstallByProjectId = new Map(
pendingServerContentInstalls.value.map((item) => [item.projectId, item]),
)
const pendingInstallByVersionId = new Map(
pendingServerContentInstalls.value.map((item) => [item.versionId, item]),
)
const pendingInstallByFileName = new Map<string, PendingServerContentInstall>()
for (const item of pendingServerContentInstalls.value) {
if (item.fileName) {
pendingInstallByFileName.set(item.fileName, item)
}
}
const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value])
const installedProjectIds = new Set(
addons.map((addon) => addon.project_id).filter((id): id is string => !!id),
const resolvedPendingProjectIds = new Set(
pendingServerContentInstalls.value
.filter((item) => addons.some((addon) => addonMatchesPendingInstall(addon, item)))
.map((item) => item.projectId),
)
const pendingItems = pendingServerContentInstalls.value
.filter((item) => !installedProjectIds.has(item.projectId))
.filter((item) => !resolvedPendingProjectIds.has(item.projectId))
.map(pendingInstallToContentItem)
const addonItems = addons.map((addon) => {
const contentItem = addonToContentItem(addon)
const installing = installingContentKeys.has(getAddonInstallKey(addon))
const pendingItem = addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null
const pendingItem =
(addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null) ??
(addon.version?.id ? pendingInstallByVersionId.get(addon.version.id) : null) ??
pendingInstallByFileName.get(addon.filename) ??
null
const installing = !!pendingItem || installingContentKeys.has(getAddonInstallKey(addon))
if (!installing || !pendingItem) {
return {
@@ -478,6 +562,26 @@ watch(
{ deep: true, immediate: true },
)
watch(
[() => contentQuery.data.value?.addons, pendingServerContentInstalls],
([addons]) => {
removeResolvedPendingServerContentInstalls(addons ?? [])
},
{ deep: true, immediate: true },
)
watch(
() => pendingServerContentInstalls.value.length > 0,
(hasPendingInstalls) => {
if (hasPendingInstalls) {
resumePendingInstallPoll()
} else {
pausePendingInstallPoll()
}
},
{ immediate: true },
)
watch(
worldId,
() => {
@@ -498,6 +602,7 @@ onMounted(() => {
})
onUnmounted(() => {
pausePendingInstallPoll()
window.removeEventListener(
pendingServerContentInstallsEvent,
handlePendingServerContentInstallsChanged,
@@ -659,9 +764,7 @@ const updatingProjectVersions = computed(() => {
? modpackVersionsQuery.data.value
: projectVersionsQuery.data.value
if (!source) return []
return [...source].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
return sortVersionsByPublishedDate(source)
})
const loadingVersions = computed(() =>
@@ -674,8 +777,12 @@ const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const currentGameVersion = computed(() => contentQuery.data.value?.game_version ?? '')
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
const currentGameVersion = computed(
() => contentQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
)
const currentLoader = computed(
() => contentQuery.data.value?.modloader ?? server.value?.loader ?? '',
)
function handleBrowseContent() {
const contentType = type.value
@@ -929,7 +1036,9 @@ async function handleModpackUpdate() {
await nextTick()
contentUpdaterModal.value?.show(mp.has_update ?? undefined)
contentUpdaterModal.value?.show(
newestModpackUpdateVersion.value?.id ?? mp.has_update ?? undefined,
)
}
function spliceVersionInCache(fullVersion: Labrinth.Versions.v2.Version) {
@@ -973,17 +1082,21 @@ function resetUpdateState() {
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
if (updatingModpack.value) {
if (event?.shiftKey) {
pendingModpackUpdateVersion.value = selectedVersion
pendingModpackUpdateVersion.value = selectedVersion
const mpSpec = contentQuery.data.value?.modpack?.spec
const currentVersionId = mpSpec?.platform === 'modrinth' ? mpSpec.version_id : undefined
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, currentGameVersion.value)
if (event?.shiftKey || !shouldShowWarning) {
handleModpackUpdateConfirm()
} else {
const mpSpec = contentQuery.data.value?.modpack?.spec
const currentVersionId = mpSpec?.platform === 'modrinth' ? mpSpec.version_id : undefined
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
pendingModpackUpdateVersion.value = selectedVersion
modpackUpdateModal.value?.show()
}
return
@@ -1048,6 +1161,7 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
function handleModpackUpdateConfirm() {
if (pendingModpackUpdateVersion.value) {
contentUpdaterModal.value?.hide()
performUpdate(pendingModpackUpdateVersion.value)
pendingModpackUpdateVersion.value = null
}
+9
View File
@@ -1658,6 +1658,15 @@
"instances.updater-modal.hide-incompatible": {
"defaultMessage": "Hide incompatible"
},
"instances.updater-modal.incompatible-update.description": {
"defaultMessage": "{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly."
},
"instances.updater-modal.incompatible-update.header": {
"defaultMessage": "Update to incompatible version?"
},
"instances.updater-modal.incompatible-update.proceed": {
"defaultMessage": "Update anyway"
},
"instances.updater-modal.loading-changelog": {
"defaultMessage": "Loading changelog..."
},
+1
View File
@@ -11,4 +11,5 @@ export * from './server-content-installing'
export * from './server-search'
export * from './tag-messages'
export * from './truncate'
export * from './version-compatibility'
export * from './vue-children'
@@ -0,0 +1,70 @@
const NON_MOD_PROJECT_TYPES = new Set(['shader', 'shaderpack', 'resourcepack', 'datapack'])
const LOADER_ALIAS_GROUPS = [
['paper', 'purpur', 'spigot', 'bukkit'],
['neoforge', 'neo'],
]
type VersionCompatibilityData = {
game_versions: string[]
loaders: string[]
}
export function normalizeLoaderAlias(loader: string) {
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
}
export function getCompatibleLoaderAliases(loader: string) {
const normalizedLoader = normalizeLoaderAlias(loader)
const aliases = new Set([normalizedLoader])
const aliasGroup = LOADER_ALIAS_GROUPS.find((group) => group.includes(normalizedLoader))
if (aliasGroup) {
for (const alias of aliasGroup) {
aliases.add(alias)
}
}
return aliases
}
export function versionChangesGameVersion(
version: VersionCompatibilityData,
currentGameVersion: string,
) {
return !!currentGameVersion && !version.game_versions.includes(currentGameVersion)
}
export function versionMatchesCompatibilityTarget(
version: VersionCompatibilityData,
target: {
gameVersion: string
loader: string
projectType?: string
},
) {
if (!target.gameVersion || !version.game_versions.includes(target.gameVersion)) {
return false
}
if (target.projectType && NON_MOD_PROJECT_TYPES.has(target.projectType)) {
return true
}
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
if (
target.projectType === 'modpack' &&
(normalizedVersionLoaders.length === 0 ||
normalizedVersionLoaders.every((loader) => loader === 'mrpack'))
) {
return true
}
if (!target.loader) {
return false
}
const loaderAliases = getCompatibleLoaderAliases(target.loader)
return normalizedVersionLoaders.some((loader) => loaderAliases.has(loader))
}