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 23:03:35 +01: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
}
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,