Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions apps/app-frontend/src/pages/instance/Mods.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/app-lib/src/state/instances/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions packages/ui/src/composables/virtual-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface Props {
bulkTotal?: number
bulkWaiting?: boolean
ariaLabel?: string
getItemId?: (item: ContentItem) => string
}

const props = withDefaults(defineProps<Props>(), {
Expand All @@ -85,6 +86,7 @@ const props = withDefaults(defineProps<Props>(), {
bulkTotal: 0,
bulkWaiting: false,
ariaLabel: undefined,
getItemId: undefined,
})

const emit = defineEmits<{
Expand All @@ -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))

Expand Down Expand Up @@ -146,15 +152,15 @@ 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 }"
>
<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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
32 changes: 20 additions & 12 deletions packages/ui/src/layouts/shared/content-tab/layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/layouts/wrapped/hosting/manage/content.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading