diff --git a/.vscode/settings.json b/.vscode/settings.json index 15ef8720e..ce98edfb6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,7 @@ { "prettier.endOfLine": "lf", "editor.formatOnSave": true, - "eslint.validate": [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact" - ], + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.detectIndentation": false, "editor.insertSpaces": false, "files.eol": "\n", diff --git a/apps/app-frontend/src/components/ui/ExportModal.vue b/apps/app-frontend/src/components/ui/ExportModal.vue index d856a9022..ea573dfef 100644 --- a/apps/app-frontend/src/components/ui/ExportModal.vue +++ b/apps/app-frontend/src/components/ui/ExportModal.vue @@ -89,14 +89,9 @@ const initFiles = async () => { disabled: folder === 'profile.json' || folder.startsWith('modrinth_logs') || - folder.startsWith('.fabric'), + folder.startsWith('.fabric') || + folder.startsWith('__MACOSX'), })) - .filter( - (pathData) => - !pathData.path.includes('.DS_Store') && - pathData.path !== 'mods/.connector' && - !pathData.path.startsWith('mods/.connector/'), - ) .forEach((pathData) => { const parent = pathData.path.split(sep).slice(0, -1).join(sep) if (parent !== '') { diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index e256d5b02..8ce11f96b 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -29,7 +29,7 @@ import { import { useQueryClient } from '@tanstack/vue-query' import { convertFileSrc } from '@tauri-apps/api/core' import type { Ref } from 'vue' -import { computed, ref, watch } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import type { LocationQuery } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' @@ -41,6 +41,7 @@ import { get_search_results_v3, get_version_many, } from '@/helpers/cache.js' +import { profile_listener } from '@/helpers/events.js' import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata' import { get as getInstance, @@ -181,6 +182,28 @@ watchServerContextChanges() await initInstanceContext() +async function refreshInstalledProjectIds() { + if (!route.query.i) return + + if (route.query.from === 'worlds') { + const worlds = await get_profile_worlds(route.query.i as string).catch(handleError) + if (!worlds) return + + const serverProjectIds = worlds + .filter((w) => w.type === 'server' && 'project_id' in w && w.project_id) + .map((w) => (w as { project_id: string }).project_id) + debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length }) + installedProjectIds.value = serverProjectIds + return + } + + const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError) + if (!ids) return + + debugLog('installedProjectIds loaded', { count: ids.length }) + installedProjectIds.value = ids +} + async function initInstanceContext() { debugLog('initInstanceContext', { queryI: route.query.i, @@ -199,24 +222,7 @@ async function initInstanceContext() { gameVersion: instance.value?.game_version, }) - if (route.query.from === 'worlds') { - get_profile_worlds(route.query.i as string) - .then((worlds) => { - const serverProjectIds = worlds - .filter((w) => w.type === 'server' && 'project_id' in w && w.project_id) - .map((w) => (w as { project_id: string }).project_id) - debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length }) - installedProjectIds.value = serverProjectIds - }) - .catch(handleError) - } else { - getInstalledProjectIds(route.query.i as string) - .then((ids) => { - debugLog('installedProjectIds loaded', { count: ids?.length }) - installedProjectIds.value = ids - }) - .catch(handleError) - } + await refreshInstalledProjectIds() if (instance.value?.linked_data?.project_id) { debugLog('checking linked project for server status', instance.value.linked_data.project_id) @@ -805,10 +811,10 @@ function getCardActions( selectedInstall.versionId, instance.value ? instance.value.path : null, 'SearchCard', - (versionId) => { + (versionId, installedProjectIds) => { setProjectInstalling(projectResult.project_id, false) if (versionId) { - onSearchResultInstalled(projectResult.project_id) + onSearchResultsInstalled(installedProjectIds ?? [projectResult.project_id]) } }, (profile) => { @@ -834,7 +840,19 @@ function onSearchResultInstalled(id: string) { markServerProjectInstalled(id) return } - newlyInstalled.value.push(id) + if (!newlyInstalled.value.includes(id)) { + newlyInstalled.value = [...newlyInstalled.value, id] + } +} + +function onSearchResultsInstalled(ids: string[]) { + if (isServerContext.value) { + for (const id of ids) { + markServerProjectInstalled(id) + } + return + } + newlyInstalled.value = Array.from(new Set([...newlyInstalled.value, ...ids])) } async function search(requestParams: string) { @@ -966,6 +984,38 @@ if (instance.value?.game_version) { await searchState.refreshSearch() +type UnlistenFn = () => void + +let isUnmounted = false +let unlistenProfiles: UnlistenFn | null = null + +onMounted(() => { + profile_listener(async (event: { event: string; profile_path_id: string }) => { + if ( + instance.value && + event.profile_path_id === instance.value.path && + event.event === 'synced' + ) { + await refreshInstalledProjectIds() + await searchState.refreshSearch() + } + }) + .then((unlisten) => { + if (isUnmounted) { + unlisten() + return + } + + unlistenProfiles = unlisten + }) + .catch(handleError) +}) + +onUnmounted(() => { + isUnmounted = true + unlistenProfiles?.() +}) + function getProjectBrowseQuery() { if (!installContext.value) return undefined return { diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index a323f939c..41af9d55b 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -99,7 +99,7 @@ import { useRouter } from 'vue-router' import ExportModal from '@/components/ui/ExportModal.vue' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue' import { trackEvent } from '@/helpers/analytics' -import { get_project_versions, get_version } from '@/helpers/cache.js' +import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js' import { profile_listener } from '@/helpers/events.js' import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content' import { @@ -451,6 +451,63 @@ async function removeMod(mod: ContentItem) { } } +function isBreakingDependency(dependency: Labrinth.Versions.v2.Dependency) { + return dependency.dependency_type === 'required' || dependency.dependency_type === 'embedded' +} + +function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item: ContentItem) { + return ( + (!!dependency.project_id && dependency.project_id === item.project?.id) || + ('version_id' in dependency && + !!dependency.version_id && + dependency.version_id === item.version?.id) + ) +} + +async function getDeleteDependencyWarning(items: ContentItem[]) { + if (props.isServerInstance) return null + + const deletingIds = new Set(items.map(getContentItemId)) + const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item))) + const versionIds = [ + ...new Set(remainingItems.map((item) => item.version?.id).filter((id): id is string => !!id)), + ] + + if (versionIds.length === 0) return null + + const versions = (await get_version_many(versionIds).catch((err) => { + handleError(err as Error) + return null + })) as Labrinth.Versions.v2.Version[] | null + + if (!versions) return null + + const versionsById = new Map(versions.map((version) => [version.id, version])) + + const dependents = remainingItems + .map((candidate) => { + const version = candidate.version?.id ? versionsById.get(candidate.version.id) : null + if (!version) return null + + const dependencies = items.filter((item) => { + if (!item.project?.id && !item.version?.id) return false + + return version.dependencies?.some( + (dependency) => + isBreakingDependency(dependency) && dependencyTargetsItem(dependency, item), + ) + }) + + return dependencies.length > 0 ? { item: candidate, dependencies } : null + }) + .filter( + (dependent): dependent is { item: ContentItem; dependencies: ContentItem[] } => + dependent !== null, + ) + + return dependents.length > 0 ? { items, dependents } : null +} + async function updateProject(mod: ContentItem) { if (!mod.file_path) return const operation = beginContentOperation(mod) @@ -481,6 +538,7 @@ async function updateProject(mod: ContentItem) { }) } catch (err) { handleError(err as Error) + throw err } finally { await refreshContentState('must_revalidate') finishContentOperation(mod, operation) @@ -885,13 +943,15 @@ async function handleModalUpdate( } else if (updatingProject.value) { const mod = updatingProject.value - if (mod.has_update && mod.update_version_id === selectedVersion.id) { - await updateProject(mod) - } else { - await switchProjectVersion(mod, selectedVersion) + try { + if (mod.has_update && mod.update_version_id === selectedVersion.id) { + await updateProject(mod) + } else { + await switchProjectVersion(mod, selectedVersion) + } + } finally { + resetUpdateState() } - - resetUpdateState() } } @@ -1081,6 +1141,7 @@ provideContentManager({ deleteItem: removeMod, bulkDeleteItems: (items: ContentItem[]) => Promise.all(items.map((item) => removeMod(item))).then(() => {}), + getDeleteDependencyWarning, refresh: () => initProjects('must_revalidate'), browse: handleBrowseContent, uploadFiles: handleUploadFiles, diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 035d12899..7d5b24dd3 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -406,6 +406,20 @@ function buildProjectHref(path, extraQuery = {}) { return qs ? `${path}?${qs}` : path } +function buildBrowseHref(path) { + const params = new URLSearchParams() + for (const [key, val] of Object.entries(route.query)) { + if (key === 'b') continue + if (Array.isArray(val)) { + for (const v of val) params.append(key, v) + } else if (val) { + params.append(key, String(val)) + } + } + const qs = params.toString() + return qs ? `${path}?${qs}` : path +} + const projectDescriptionHref = computed(() => buildProjectHref(`/project/${route.params.id}`)) const versionsHref = computed(() => buildProjectHref(`/project/${route.params.id}/versions`, instanceFilters.value), @@ -416,7 +430,7 @@ const projectBrowseBackUrl = computed(() => { const browsePath = route.query.b if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath const type = data.value?.project_type ? `${data.value.project_type}` : 'mod' - return `/browse/${type}` + return buildBrowseHref(`/browse/${type}`) }) const projectInstallContext = computed(() => { @@ -725,10 +739,11 @@ async function install(version) { version, instance.value ? instance.value.path : null, 'ProjectPage', - (version) => { + (version, installedProjectIds) => { installing.value = false - if (instance.value && version) { + const installedIds = installedProjectIds ?? [data.value.id] + if (instance.value && version && installedIds.includes(data.value.id)) { installed.value = true installedVersion.value = version } diff --git a/apps/app-frontend/src/pages/project/Version.vue b/apps/app-frontend/src/pages/project/Version.vue index 655e88cb0..8a37f765e 100644 --- a/apps/app-frontend/src/pages/project/Version.vue +++ b/apps/app-frontend/src/pages/project/Version.vue @@ -5,7 +5,7 @@ :current-title="version.name" :link-stack="[ { - href: `/project/${route.params.id}/versions`, + href: buildProjectHref(`/project/${route.params.id}/versions`), label: 'Versions', }, ]" @@ -249,6 +249,19 @@ const author = computed(() => const displayDependencies = ref({}) +function buildProjectHref(path) { + const params = new URLSearchParams() + for (const [key, val] of Object.entries(route.query)) { + if (Array.isArray(val)) { + for (const v of val) params.append(key, v) + } else if (val) { + params.append(key, String(val)) + } + } + const qs = params.toString() + return qs ? `${path}?${qs}` : path +} + async function refreshDisplayDependencies() { const projectIds = new Set() const versionIds = new Set() @@ -282,7 +295,7 @@ async function refreshDisplayDependencies() { icon: project?.icon_url, title: project?.title || project?.name, subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`, - link: `/project/${project.slug}/version/${version.id}`, + link: buildProjectHref(`/project/${project.slug}/version/${version.id}`), } } else { const project = dependencies.projects.find((obj) => obj.id === dependency.project_id) @@ -292,7 +305,7 @@ async function refreshDisplayDependencies() { icon: project?.icon_url, title: project?.title || project?.name, subtitle: `${dependency.dependency_type}`, - link: `/project/${project.slug}`, + link: buildProjectHref(`/project/${project.slug}`), } } else { return { diff --git a/apps/app-frontend/src/pages/project/Versions.vue b/apps/app-frontend/src/pages/project/Versions.vue index 0b11e6895..f7aa3d0e1 100644 --- a/apps/app-frontend/src/pages/project/Versions.vue +++ b/apps/app-frontend/src/pages/project/Versions.vue @@ -5,7 +5,7 @@ :game-versions="gameVersions" :versions="versions" :project="project" - :version-link="(version) => `/project/${project.id}/version/${version.id}`" + :version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)" >