mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 10:34:53 +00:00
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
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user