You've already forked AstralRinth
fix: content tab uniqueness regression (#6156)
* fix: content tab uniqueness regression Closes: #6154 * fix: further regressions * fix: lint * fix: lint
This commit is contained in:
@@ -230,8 +230,12 @@ function fileNameFromPath(path: string) {
|
||||
return path.split('/').pop() ?? path
|
||||
}
|
||||
|
||||
function getContentItemId(item: ContentItem | null | undefined) {
|
||||
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
|
||||
}
|
||||
|
||||
function getContentOperationKeys(item: ContentItem) {
|
||||
return [item.id, item.file_path, item.file_name, item.project?.id, item.version?.id].filter(
|
||||
return [getContentItemId(item), item.file_path, item.file_name].filter(
|
||||
(key): key is string => !!key,
|
||||
)
|
||||
}
|
||||
@@ -478,10 +482,11 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string) {
|
||||
const item = projects.value.find((p) => p.id === id)
|
||||
const item = projects.value.find((p) => getContentItemId(p) === id)
|
||||
if (!item?.has_update || !item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
const itemId = getContentItemId(item)
|
||||
|
||||
debug('handleUpdate triggered', {
|
||||
fileName: item.file_name,
|
||||
@@ -542,7 +547,8 @@ async function handleUpdate(id: string) {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
@@ -595,6 +601,7 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
if (!item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
const itemId = getContentItemId(item)
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
@@ -610,7 +617,8 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
@@ -1055,8 +1063,9 @@ provideContentManager({
|
||||
showContentHint,
|
||||
dismissContentHint,
|
||||
shareItems: handleShareItems,
|
||||
getItemId: getContentItemId,
|
||||
mapToTableItem: (item: ContentItem) => ({
|
||||
id: item.id,
|
||||
id: getContentItemId(item),
|
||||
project: item.project ?? {
|
||||
id: item.file_name,
|
||||
slug: null,
|
||||
|
||||
@@ -32,12 +32,12 @@ use std::io::Cursor;
|
||||
/// Content item with rich metadata for frontend display
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItem {
|
||||
/// Unique identifier (the file name)
|
||||
/// Display file name.
|
||||
pub file_name: String,
|
||||
/// Relative path to the file within the profile
|
||||
pub file_path: String,
|
||||
/// Stable frontend identifier (SHA1 hash of file content, survives renames).
|
||||
/// Not a project or version ID.
|
||||
/// SHA1 hash of file content. Stable across renames, but not unique when
|
||||
/// duplicate files have identical contents.
|
||||
pub id: String,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
|
||||
@@ -84,9 +84,13 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
|
||||
const start = Math.floor(relativeScrollTop / itemHeight)
|
||||
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
|
||||
const rangeSize = visibleCount + bufferSize * 2
|
||||
|
||||
const rangeStart = Math.max(0, start - bufferSize)
|
||||
const rangeEnd = Math.min(items.value.length, start + visibleCount + bufferSize * 2)
|
||||
const rangeStart = Math.min(
|
||||
Math.max(0, start - bufferSize),
|
||||
Math.max(0, items.value.length - rangeSize),
|
||||
)
|
||||
const rangeEnd = Math.min(items.value.length, rangeStart + rangeSize)
|
||||
|
||||
return {
|
||||
start: rangeStart,
|
||||
|
||||
@@ -74,6 +74,7 @@ interface Props {
|
||||
bulkTotal?: number
|
||||
bulkWaiting?: boolean
|
||||
ariaLabel?: string
|
||||
getItemId?: (item: ContentItem) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -85,6 +86,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
bulkTotal: 0,
|
||||
bulkWaiting: false,
|
||||
ariaLabel: undefined,
|
||||
getItemId: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -102,6 +104,10 @@ const iconStackWidth = computed(() => {
|
||||
return 32 + (visibleItems.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
|
||||
})
|
||||
|
||||
function resolveItemId(item: ContentItem) {
|
||||
return props.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
|
||||
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
|
||||
|
||||
@@ -146,7 +152,7 @@ const bulkProgressMessage = computed(() => {
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.id"
|
||||
:key="resolveItemId(item)"
|
||||
v-tooltip="item.project?.title ?? item.file_name"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
|
||||
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleItems.length - index }"
|
||||
@@ -154,7 +160,7 @@ const bulkProgressMessage = computed(() => {
|
||||
<Avatar
|
||||
:src="item.project?.icon_url"
|
||||
:alt="item.project?.title ?? item.file_name"
|
||||
:tint-by="item.id"
|
||||
:tint-by="resolveItemId(item)"
|
||||
size="100%"
|
||||
no-shadow
|
||||
class="selected-content-avatar"
|
||||
|
||||
@@ -3,21 +3,27 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
export function useContentSelection(items: Ref<ContentItem[]>) {
|
||||
export function useContentSelection(
|
||||
items: Ref<ContentItem[]>,
|
||||
getItemId: (item: ContentItem) => string,
|
||||
) {
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
items.value.filter((item) => selectedIds.value.includes(item.id)),
|
||||
items.value.filter((item) => selectedIds.value.includes(getItemId(item))),
|
||||
)
|
||||
|
||||
watch(items, (newItems) => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
const validIds = new Set(newItems.map((item) => item.id))
|
||||
const pruned = selectedIds.value.filter((id) => validIds.has(id))
|
||||
if (pruned.length !== selectedIds.value.length) {
|
||||
selectedIds.value = pruned
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => items.value.map(getItemId),
|
||||
(newIds) => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
const validIds = new Set(newIds)
|
||||
const pruned = selectedIds.value.filter((id) => validIds.has(id))
|
||||
if (pruned.length !== selectedIds.value.length) {
|
||||
selectedIds.value = pruned
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = []
|
||||
|
||||
@@ -151,6 +151,10 @@ const messages = defineMessages({
|
||||
|
||||
const ctx = injectContentManager()
|
||||
|
||||
function getItemId(item: ContentItem) {
|
||||
return ctx.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
|
||||
const sortMode = ref<SortMode>('alphabetical-asc')
|
||||
|
||||
@@ -227,6 +231,7 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
|
||||
|
||||
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
|
||||
ctx.items,
|
||||
getItemId,
|
||||
)
|
||||
|
||||
const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation()
|
||||
@@ -261,13 +266,12 @@ const filteredItems = computed(() => {
|
||||
const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
const items = filteredItems.value.map((item) => {
|
||||
const base = ctx.mapToTableItem(item)
|
||||
const id = getItemId(item)
|
||||
return {
|
||||
...base,
|
||||
id,
|
||||
disabled:
|
||||
isChanging(base.id) ||
|
||||
ctx.isBusy.value ||
|
||||
isBulkOperating.value ||
|
||||
item.installing === true,
|
||||
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
|
||||
installing: item.installing === true,
|
||||
hasUpdate: item.has_update,
|
||||
isClientOnly:
|
||||
@@ -314,7 +318,7 @@ const pendingDeletionItems = ref<ContentItem[]>([])
|
||||
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
|
||||
|
||||
function handleDeleteById(id: string, event?: MouseEvent) {
|
||||
const item = ctx.items.value.find((i) => i.id === id)
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (item) {
|
||||
pendingDeletionItems.value = [item]
|
||||
if (event?.shiftKey) {
|
||||
@@ -356,11 +360,14 @@ async function confirmDelete() {
|
||||
|
||||
if (itemsToDelete.length === 1) {
|
||||
const item = itemsToDelete[0]
|
||||
const id = item.id
|
||||
const id = getItemId(item)
|
||||
markChanging(id)
|
||||
await ctx.deleteItem(item)
|
||||
removeFromSelection(id)
|
||||
unmarkChanging(id)
|
||||
try {
|
||||
await ctx.deleteItem(item)
|
||||
removeFromSelection(id)
|
||||
} finally {
|
||||
unmarkChanging(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,14 +376,14 @@ async function confirmDelete() {
|
||||
itemsToDelete,
|
||||
async (item) => {
|
||||
await ctx.deleteItem(item)
|
||||
removeFromSelection(item.id)
|
||||
removeFromSelection(getItemId(item))
|
||||
},
|
||||
{ onComplete: clearSelection },
|
||||
)
|
||||
}
|
||||
|
||||
async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
const item = ctx.items.value.find((i) => i.id === id)
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (!item) return
|
||||
markChanging(id)
|
||||
try {
|
||||
@@ -431,7 +438,7 @@ function handleUpdateById(id: string) {
|
||||
}
|
||||
|
||||
function handleSwitchVersionById(id: string) {
|
||||
const item = ctx.items.value.find((i) => i.id === id)
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (item) {
|
||||
ctx.switchVersion?.(item)
|
||||
}
|
||||
@@ -758,6 +765,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:bulk-total="bulkTotal"
|
||||
:bulk-waiting="bulkWaiting"
|
||||
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
|
||||
:get-item-id="getItemId"
|
||||
@clear="clearSelection"
|
||||
@enable="bulkEnable"
|
||||
@disable="bulkDisable"
|
||||
|
||||
@@ -77,6 +77,9 @@ export interface ContentManagerContext {
|
||||
// Share support (optional — when undefined, share button becomes hidden entirely)
|
||||
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
|
||||
|
||||
// Stable per-row identity. ContentItem.id can be a content hash, so it is not always unique.
|
||||
getItemId?: (item: ContentItem) => string
|
||||
|
||||
// Bulk operation guard — set by layout, checked by providers to suppress refreshes
|
||||
isBulkOperating?: Ref<boolean>
|
||||
|
||||
|
||||
@@ -526,6 +526,10 @@ function getContentItemDisplayKey(item: ContentItem) {
|
||||
return item.project?.id ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
function getContentItemId(item: ContentItem) {
|
||||
return item.file_name ?? item.id
|
||||
}
|
||||
|
||||
function mergeFragileContentItems(items: ContentItem[]) {
|
||||
const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item]))
|
||||
const mergedItems = displayedContentItems.value.map((item) => {
|
||||
@@ -980,7 +984,7 @@ async function handleBulkUpdate(items: ContentItem[]) {
|
||||
}
|
||||
|
||||
async function handleUpdateItem(id: string) {
|
||||
const item = contentItems.value.find((i) => i.id === id)
|
||||
const item = contentItems.value.find((i) => getContentItemId(i) === id)
|
||||
if (!item?.has_update || !item.project?.id || !item.version?.id) return
|
||||
|
||||
updatingModpack.value = false
|
||||
@@ -1220,13 +1224,14 @@ provideContentManager({
|
||||
openSettings: () => openServerSettings({ tabId: 'installation' }),
|
||||
switchVersion: handleSwitchVersion,
|
||||
getOverflowOptions,
|
||||
getItemId: getContentItemId,
|
||||
mapToTableItem: (item) => {
|
||||
const projectType = item.project_type ?? type.value
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
const hasModrinthProject = !!addon?.project_id || (!!item.installing && !!item.project?.id)
|
||||
const projectSlugOrId = item.project.slug ?? item.project.id
|
||||
return {
|
||||
id: item.id,
|
||||
id: getContentItemId(item),
|
||||
project: item.project,
|
||||
projectLink: hasModrinthProject ? `/${projectType}/${projectSlugOrId}` : undefined,
|
||||
version: item.version,
|
||||
|
||||
Reference in New Issue
Block a user