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