feat: instances v2 (#6431)

* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-25 21:19:29 +00:00
committed by GitHub
co-authored by DJCheesusReal Truman Gao
parent ef4044534f
commit 734720e11e
353 changed files with 24745 additions and 9771 deletions
+17 -17
View File
@@ -24,8 +24,8 @@ import {
} from '@tauri-apps/plugin-fs'
import { onUnmounted, ref, watch } from 'vue'
import { profile_listener } from '@/helpers/events'
import { get_full_path } from '@/helpers/profile'
import { instance_listener } from '@/helpers/events'
import { get_full_path } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { highlightInFolder } from '@/helpers/utils'
@@ -62,9 +62,9 @@ const error = ref<Error | null>(null)
const currentPath = ref('')
const editingFile = ref<EditingFile | null>(null)
debug('setup: start, instance.path =', props.instance.path)
debug('setup: start, instance.id =', props.instance.id)
instanceRoot.value = await get_full_path(props.instance.path)
instanceRoot.value = await get_full_path(props.instance.id)
debug('setup: instanceRoot =', instanceRoot.value)
await refresh()
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
@@ -221,7 +221,7 @@ async function handleWriteFile(path: string, content: string) {
async function handleDownloadFile(path: string, _fileName: string) {
await invoke('plugin:files|file_save_as', {
instancePath: props.instance.path,
instanceId: props.instance.id,
filePath: path,
})
}
@@ -275,7 +275,7 @@ async function handleUploadFiles(files: File[]) {
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
try {
return await invoke('plugin:files|file_extract_zip', {
instancePath: props.instance.path,
instanceId: props.instance.id,
filePath: path,
overrideConflicts: override,
dryRun: dry,
@@ -289,28 +289,28 @@ async function handleExtractFile(path: string, override: boolean, dry: boolean)
}
}
debug('setup: registering profile_listener')
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
debug('profile_listener: event =', event.event, 'path =', event.profile_path_id)
if (event.profile_path_id === props.instance.path && event.event === 'synced') {
debug('profile_listener: synced event matched, calling refresh')
debug('setup: registering instance_listener')
const unlistenInstances = await instance_listener(
async (event: { event: string; instance_id: string }) => {
debug('instance_listener: event =', event.event, 'path =', event.instance_id)
if (event.instance_id === props.instance.id && event.event === 'synced') {
debug('instance_listener: synced event matched, calling refresh')
await refresh()
}
},
)
debug('setup: profile_listener registered')
debug('setup: instance_listener registered')
onUnmounted(() => {
unlistenProfiles()
unlistenInstances()
})
watch(
() => props.instance.path,
() => props.instance.id,
async () => {
debug('watch instance.path: changed to', props.instance.path)
debug('watch instance.id: changed to', props.instance.id)
firstPaintPending.value = true
instanceRoot.value = await get_full_path(props.instance.path)
instanceRoot.value = await get_full_path(props.instance.id)
currentPath.value = ''
await refresh()
},
+92 -50
View File
@@ -6,7 +6,7 @@
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal
:key="instance.path"
:key="instance.id"
ref="settingsModal"
:instance="instance"
:offline="offline"
@@ -19,7 +19,7 @@
:src="icon ? icon : undefined"
:alt="instance.name"
size="64px"
:tint-by="instance.path"
:tint-by="instance.id"
/>
</template>
<template #title>
@@ -78,7 +78,7 @@
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
:tint-by="instance.id"
size="24px"
/>
<router-link
@@ -190,13 +190,17 @@
{
id: 'open-folder',
action: () => {
if (instance) showProfileInFolder(instance.path)
if (instance) showInstanceInFolder(instance.id)
},
},
{
id: 'export-mrpack',
action: () => exportModal?.show(),
},
{
id: 'create-shortcut',
action: () => createShortcut(),
},
]"
>
<MoreVerticalIcon />
@@ -204,6 +208,7 @@
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
<template #create-shortcut> <ExternalIcon /> Create shortcut </template>
</OverflowMenu>
</ButtonStyled>
</div>
@@ -214,14 +219,10 @@
<NavTabs :links="tabs" />
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView
v-if="route.path.startsWith('/instance')"
v-slot="{ Component }"
:key="instance.path"
>
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
<template v-if="Component">
<Suspense
:key="instance.path"
:key="instance.id"
@pending="subpagePending = true"
@resolve="subpagePending = false"
>
@@ -309,7 +310,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -319,12 +320,13 @@ import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { instance_listener, process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
@@ -333,12 +335,13 @@ import { useBreadcrumbs, useTheming } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
@@ -377,7 +380,17 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
function isContentSubpageRoute(routeName = route.name) {
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance')) {
displayedInstanceRoute.value = nextRoute
}
},
{ immediate: true },
)
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
@@ -395,15 +408,12 @@ async function fetchInstance() {
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
? loadInstanceContentData(nextInstance.path, undefined, handleError)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
if (!offline.value && nextInstance?.linked_data && nextInstance.linked_data.project_id) {
if (!offline.value && nextInstance?.link && nextInstance.link.project_id) {
try {
nextLinkedProjectV3 = await get_project_v3(
nextInstance.linked_data.project_id,
'must_revalidate',
)
nextLinkedProjectV3 = await get_project_v3(nextInstance.link.project_id, 'must_revalidate')
if (nextLinkedProjectV3?.minecraft_server != null) {
nextIsServerInstance = true
@@ -424,8 +434,8 @@ async function fetchInstance() {
if (nextInstance) {
queryClient.prefetchQuery({
queryKey: ['worlds', nextInstance.path],
queryFn: () => refreshWorlds(nextInstance.path),
queryKey: ['worlds', nextInstance.id],
queryFn: () => refreshWorlds(nextInstance.id),
staleTime: 30_000,
})
}
@@ -454,7 +464,7 @@ function fetchDeferredData() {
async function updatePlayState() {
if (!route.params.id) return
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
}
@@ -469,7 +479,9 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
const basePath = computed(
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
)
/**
* Per-route layout mode.
@@ -480,7 +492,7 @@ const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id
* Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`.
*/
const renderMode = computed<'scroll' | 'fixed'>(() =>
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
displayedInstanceRoute.value.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
@@ -519,8 +531,8 @@ if (instance.value) {
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
link: displayedInstanceRoute.value.path,
query: displayedInstanceRoute.value.query,
})
}
@@ -538,7 +550,7 @@ const startInstance = async (context: string) => {
await run(route.params.id as string)
playing.value = true
} catch (err) {
handleSevereError(err, { profilePath: route.params.id as string })
handleSevereError(err, { instanceId: route.params.id as string })
}
loading.value = false
@@ -564,10 +576,10 @@ const stopInstance = async (context: string) => {
}
const handlePlayServer = async () => {
if (!instance.value?.linked_data?.project_id) return
if (!instance.value?.link?.project_id) return
loading.value = true
try {
await playServerProject(instance.value.linked_data.project_id)
await playServerProject(instance.value.link.project_id)
} finally {
await updatePlayState()
loading.value = false
@@ -575,7 +587,39 @@ const handlePlayServer = async () => {
}
const repairInstance = async () => {
await finish_install(instance.value).catch(handleError)
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(instance.value.id, {
type: 'fromVersionId',
project_id: instance.value.link.project_id ?? instance.value.link.server_project_id ?? '',
version_id: instance.value.link.version_id ?? instance.value.link.content_version_id ?? '',
title: instance.value.name,
}).catch(handleError)
} else {
await install_existing_instance(instance.value.id, false).catch(handleError)
}
}
const createShortcut = async () => {
if (!instance.value) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
addNotification({
type: 'success',
title: 'Shortcut created',
})
} catch (error: unknown) {
addNotification({
type: 'error',
title: `Error creating shortcut`,
text: `${error}`,
})
}
}
const handleRightClick = (event: MouseEvent) => {
@@ -628,7 +672,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
})
break
case 'open_folder':
if (instance.value) await showProfileInFolder(instance.value.path)
if (instance.value) await showInstanceInFolder(instance.value.id)
break
case 'copy_path': {
if (instance.value) {
@@ -640,9 +684,9 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
}
}
const unlistenProfiles = await profile_listener(
async (event: { profile_path_id: string; event: string }) => {
if (event.profile_path_id !== route.params.id) return
const unlistenInstances = await instance_listener(
async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== route.params.id) return
if (event.event === 'removed' || route.path === '/') {
if (route.path !== '/') {
await router.push({ path: '/' })
@@ -656,20 +700,18 @@ const unlistenProfiles = await profile_listener(
}
return handleError(err)
})
if (!instance.value?.linked_data?.project_id) {
if (!instance.value?.link?.project_id) {
linkedProjectV3.value = undefined
isServerInstance.value = false
}
},
)
const unlistenProcesses = await process_listener(
(e: { event: string; profile_path_id: string }) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
},
)
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
if (e.event === 'finished' && e.instance_id === route.params.id) {
playing.value = false
}
})
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
@@ -701,10 +743,10 @@ const timePlayedHumanized = computed(() => {
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
const profilePath = route.params.id
if (profilePath) {
const { destroy } = useInstanceConsole(profilePath)
unlistenInstances()
const instanceId = displayedInstanceRoute.value.params.id
if (instanceId) {
const { destroy } = useInstanceConsole(instanceId)
destroy()
}
})
+11 -13
View File
@@ -55,7 +55,7 @@ const props = defineProps({
},
})
const profilePathId = computed(() => route.params.id)
const instanceId = computed(() => route.params.id)
const {
liveConsole,
historicalConsole,
@@ -64,7 +64,7 @@ const {
getHistoricalContent,
invalidate,
clearLive,
} = useInstanceConsole(profilePathId.value)
} = useInstanceConsole(instanceId.value)
await hydrate()
@@ -89,7 +89,7 @@ function buildLogList(rawLogs) {
const logs = ref(buildLogList([]))
void getHistoricalLogs(props.instance.path)
void getHistoricalLogs()
.then((allLogs) => {
logs.value = buildLogList(allLogs)
})
@@ -146,9 +146,9 @@ const deleteDisabled = computed(() => {
async function deleteSelectedLog() {
const log = selectedLog.value
if (!log || log.live) return
await delete_logs_by_filename(props.instance.path, log.log_type, log.filename)
await delete_logs_by_filename(props.instance.id, log.log_type, log.filename)
invalidate()
const freshLogs = await getHistoricalLogs(props.instance.path)
const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs)
selectedLogIndex.value = 0
}
@@ -186,11 +186,9 @@ watch(selectedLogIndex, async (newIndex) => {
return
}
const output = await get_output_by_filename(
props.instance.path,
log.log_type,
log.filename,
).catch(handleError)
const output = await get_output_by_filename(props.instance.id, log.log_type, log.filename).catch(
handleError,
)
if (output) {
historicalConsole.clear()
historicalConsole.addLegacyLog(output)
@@ -204,7 +202,7 @@ if (!props.playing) {
}
const unlistenLog = await log_listener((payload) => {
if (payload.profile_path_id !== profilePathId.value) return
if (payload.instance_id !== instanceId.value) return
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
@@ -214,7 +212,7 @@ const unlistenLog = await log_listener((payload) => {
})
const unlistenProcesses = await process_listener(async (e) => {
if (e.profile_path_id !== profilePathId.value) return
if (e.instance_id !== instanceId.value) return
if (e.event === 'launched') {
liveConsole.clear()
invalidate()
@@ -222,7 +220,7 @@ const unlistenProcesses = await process_listener(async (e) => {
}
if (e.event === 'finished') {
invalidate()
const freshLogs = await getHistoricalLogs(props.instance.path)
const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs)
void analyseForCrash()
}
+339 -165
View File
@@ -10,21 +10,21 @@
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="linkedModpackProject?.title"
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackContentBulkToggle"
@bulk:disable="handleModpackContentBulkToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
@@ -40,17 +40,17 @@
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.linked_data?.version_id ?? '')
? (instance.link?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url
updatingModpack ? displayedModpackProject?.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (linkedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
@@ -69,6 +69,7 @@
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
@@ -91,6 +92,7 @@ import {
versionChangesGameVersion,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -101,25 +103,28 @@ 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, get_version_many } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
instance_bulk_update_progress_listener,
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import {
add_project_from_path,
add_project_from_version,
duplicate,
edit,
get,
get_linked_modpack_content,
list,
remove_project,
switch_project_version_with_dependencies,
toggle_disable_project,
update_all,
update_managed_modrinth_version,
update_project,
} from '@/helpers/profile'
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { installVersionDependencies } from '@/store/install'
import { useTheming } from '@/store/state'
const messages = defineMessages({
shareTitle: {
@@ -146,16 +151,33 @@ const messages = defineMessages({
id: 'app.instance.mods.content-type-project',
defaultMessage: 'project',
},
bulkUpdateResolvingVersions: {
id: 'app.instance.mods.bulk-update.resolving-versions',
defaultMessage: 'Resolving versions...',
},
bulkUpdateDownloadingProjects: {
id: 'app.instance.mods.bulk-update.downloading-projects',
defaultMessage: 'Downloading {current, number}/{total, number} projects...',
},
bulkUpdateFinishing: {
id: 'app.instance.mods.bulk-update.finishing',
defaultMessage: 'Finishing update...',
},
})
let savedModalState: ModpackContentModalState | null = null
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const { installingItems } = injectContentInstall()
const { installingItems, installRevisionByInstance, installFailureRevisionByInstance } =
injectContentInstall()
const router = useRouter()
const queryClient = useQueryClient()
const debug = useDebugLogger('Mods:ContentUpdate')
const themeStore = useTheming()
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
const props = defineProps<{
instance: GameInstance
@@ -164,13 +186,18 @@ const props = defineProps<{
preloadedContent?: InstanceContentData | null
}>()
const loading = ref(true)
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
}
const loading = ref(!hasPreloadedContent(props.preloadedContent))
const projects = ref<ContentItem[]>([])
const installingBuffer = ref<ContentItem[]>([])
const handledInstallRevision = ref(0)
watch(
() => installingItems.value.get(props.instance.path),
() => installingItems.value.get(props.instance.id),
(items) => {
if (items && items.length > 0) {
installingBuffer.value = [...items]
@@ -188,25 +215,69 @@ watch(projects, (newProjects) => {
})
const mergedProjects = computed<ContentItem[]>(() => {
const active = installingItems.value.get(props.instance.path)
const active = installingItems.value.get(props.instance.id)
const pending = active ?? installingBuffer.value
if (pending.length === 0) return projects.value
const realProjectIds = new Set(projects.value.map((p) => p.project?.id).filter(Boolean))
const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean))
const displayProjects = projects.value.map((project) =>
project.project?.id && pendingProjectIds.has(project.project.id)
? { ...project, installing: true }
: project,
)
const realProjectIds = new Set(displayProjects.map((p) => p.project?.id).filter(Boolean))
const placeholders = pending.filter((item) => !realProjectIds.has(item.project?.id))
return placeholders.length > 0 ? [...projects.value, ...placeholders] : projects.value
return placeholders.length > 0 ? [...displayProjects, ...placeholders] : displayProjects
})
watch(
() => installFailureRevisionByInstance.value.get(props.instance.id) ?? 0,
(revision, previousRevision) => {
if (revision === previousRevision) return
installingBuffer.value = []
},
)
const linkedModpackProject = ref<ContentModpackCardProject | null>(null)
const linkedModpackVersion = ref<ContentModpackCardVersion | null>(null)
const linkedModpackOwner = ref<ContentOwner | null>(null)
const linkedModpackCategories = ref<ContentModpackCardCategory[]>([])
const linkedModpackHasUpdate = ref(false)
const linkedModpackUpdateVersionId = ref<string | null>(null)
const localImportedModpackUnlinked = ref(false)
const localImportedModpackProject = computed<ContentModpackCardProject | null>(() => {
const link = props.instance.link
if (localImportedModpackUnlinked.value || link?.type !== 'imported_modpack') return null
return {
id: link.filename ?? props.instance.id,
slug: link.filename ?? props.instance.id,
title: link.name ?? props.instance.name,
icon_url: props.instance.icon_path ? convertFileSrc(props.instance.icon_path) : undefined,
description: '',
filename: link.filename ?? undefined,
}
})
const displayedModpackProject = computed(
() => linkedModpackProject.value ?? localImportedModpackProject.value,
)
watch(
() => props.instance.link,
() => {
localImportedModpackUnlinked.value = false
},
)
const isModpackUpdating = ref(false)
const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isPackLocked = computed(() => props.instance?.linked_data?.locked ?? false)
const isPackLocked = computed(
() =>
props.instance?.link?.type === 'modrinth_modpack' ||
props.instance?.link?.type === 'server_project_modpack',
)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
const exportModal = ref(null)
@@ -214,11 +285,16 @@ const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.path])
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQuery = useQuery({
queryKey: modpackContentQueryKey,
queryFn: () => get_linked_modpack_content(props.instance.path),
enabled: computed(() => !!props.instance?.path && !!props.instance?.linked_data),
queryFn: () => get_linked_modpack_content(props.instance.id),
enabled: computed(
() =>
!!props.instance?.id &&
!!props.instance?.link &&
props.instance.install_stage === 'installed',
),
})
// TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow.
@@ -239,6 +315,38 @@ function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function matchesContentItem(
item: ContentItem,
target: ContentItem,
originalFileName: string,
originalFilePath?: string,
) {
if (item.file_name === originalFileName || item.file_path === originalFilePath) return true
const projectId = target.project?.id
if (!projectId || item.project?.id !== projectId) return false
const versionId = target.version?.id
return !versionId || item.version?.id === versionId
}
function updateLinkedModpackContentCache(
target: ContentItem,
originalFileName: string,
originalFilePath: string | undefined,
updates: Partial<ContentItem>,
) {
queryClient.setQueryData<ContentItem[]>(modpackContentQueryKey.value, (items) => {
if (!items) return items
return items.map((item) =>
matchesContentItem(item, target, originalFileName, originalFilePath)
? { ...item, ...updates }
: item,
)
})
}
function getContentItemId(item: ContentItem | null | undefined) {
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
}
@@ -254,6 +362,10 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
item.installing = busy
modpackContentModal.value?.updateItem(originalFileName, {
@@ -362,7 +474,7 @@ async function handleBrowseContent() {
if (!props.instance) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.path },
query: { i: props.instance.id },
})
}
@@ -376,7 +488,7 @@ async function handleUploadFiles() {
const path = (file as { path?: string }).path ?? file
const fileName = typeof path === 'string' ? (path.split('/').pop() ?? path) : String(path)
try {
await add_project_from_path(props.instance.path, path)
await add_project_from_path(props.instance.id, path)
addedFiles.push(fileName)
} catch (e) {
handleError(e as Error)
@@ -402,21 +514,28 @@ async function handleUploadFiles() {
}
}
async function toggleDisableMod(mod: ContentItem) {
async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
const originalFilePath = mod.file_path
try {
const newPath = await toggle_disable_project(props.instance.path, mod.file_path)
const newPath = await toggle_disable_project(props.instance.id, mod.file_path, desiredEnabled)
const newFileName = fileNameFromPath(newPath)
const enabled = !newPath.endsWith('.disabled')
mod.file_path = newPath
mod.file_name = newFileName
mod.enabled = !mod.enabled
mod.enabled = enabled
modpackContentModal.value?.updateItem(operation.originalFileName, {
file_path: newPath,
file_name: newFileName,
enabled: mod.enabled,
enabled,
})
updateLinkedModpackContentCache(mod, operation.originalFileName, originalFilePath, {
file_path: newPath,
file_name: newFileName,
enabled,
})
trackEvent('InstanceProjectDisable', {
@@ -425,7 +544,7 @@ async function toggleDisableMod(mod: ContentItem) {
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !mod.enabled,
disabled: !enabled,
})
} catch (err) {
handleError(err as Error)
@@ -443,7 +562,7 @@ async function removeMod(mod: ContentItem) {
try {
const removedPath = mod.file_path
await remove_project(props.instance.path, removedPath)
await remove_project(props.instance.id, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
@@ -517,26 +636,68 @@ async function getDeleteDependencyWarning(items: ContentItem[]) {
return dependents.length > 0 ? { items, dependents } : null
}
function formatBulkUpdateProgress(progress: InstanceBulkUpdateProgress): BulkOperationStatus {
if (progress.stage === 'resolving_versions') {
return {
message: formatMessage(messages.bulkUpdateResolvingVersions),
waiting: true,
}
}
if (progress.stage === 'finishing') {
return {
message: formatMessage(messages.bulkUpdateFinishing),
progress: progress.current,
total: progress.total,
}
}
return {
message: formatMessage(messages.bulkUpdateDownloadingProjects, {
current: progress.current,
total: progress.total,
}),
progress: progress.current,
total: progress.total,
}
}
async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus) => void) {
let unlisten: (() => void) | null = null
try {
if (onProgress) {
onProgress({
message: formatMessage(messages.bulkUpdateResolvingVersions),
waiting: true,
})
unlisten = await instance_bulk_update_progress_listener((progress) => {
if (progress.instanceId !== props.instance.id) return
onProgress(formatBulkUpdateProgress(progress))
})
}
await update_all(props.instance.id)
await refreshContentState('must_revalidate')
} catch (err) {
handleError(err as Error)
throw err
} finally {
unlisten?.()
}
}
async function updateProject(mod: ContentItem) {
if (!mod.file_path) return
if (!canUpdateProject(mod)) return
const operation = beginContentOperation(mod)
if (!operation) return
try {
const updateVersionId = mod.update_version_id
await update_project(props.instance.path, mod.file_path)
if (updateVersionId) {
const versionData = await get_version(updateVersionId, 'must_revalidate').catch(handleError)
if (versionData) {
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, versionData, 'update').catch(handleError)
}
}
}
const updateVersionId = mod.update_version_id!
await switch_project_version_with_dependencies(
props.instance.id,
mod.file_path,
updateVersionId,
)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
@@ -560,27 +721,9 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
if (!operation) return
const oldPath = mod.file_path
const wasDisabled = mod.enabled === false || oldPath.endsWith('.disabled')
let newPath: string | null = null
let shouldRemoveNewOnError = false
try {
newPath = await add_project_from_version(props.instance.path, version.id, 'update')
shouldRemoveNewOnError = newPath !== oldPath
if (wasDisabled) {
newPath = await toggle_disable_project(props.instance.path, newPath)
}
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, version, 'update').catch(handleError)
}
shouldRemoveNewOnError = false
if (newPath !== oldPath) {
await remove_project(props.instance.path, oldPath)
}
await switch_project_version_with_dependencies(props.instance.id, oldPath, version.id)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
@@ -590,9 +733,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
project_type: mod.project_type,
})
} catch (err) {
if (shouldRemoveNewOnError && newPath && newPath !== oldPath) {
await remove_project(props.instance.path, newPath).catch(() => {})
}
handleError(err as Error)
} finally {
await refreshContentState('must_revalidate')
@@ -602,7 +742,7 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
async function handleUpdate(id: string) {
const item = projects.value.find((p) => getContentItemId(p) === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
if (!item || !canUpdateProject(item) || !item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
@@ -642,11 +782,11 @@ async function handleUpdate(id: string) {
updateVersionId: item.update_version_id,
},
instance: {
path: props.instance.path,
path: props.instance.id,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
link: props.instance.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
@@ -738,18 +878,18 @@ async function handleSwitchVersion(item: ContentItem) {
updatingProjectVersions.value = versions
}
async function handleModpackContentToggle(item: ContentItem) {
await toggleDisableDebounced(item)
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[]) {
await Promise.all(items.map((item) => toggleDisableMod(item)))
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
async function handleModpackContent() {
if (!props.instance?.path) return
if (!props.instance?.id) return
if (modpackContentQuery.data.value !== undefined) {
if (modpackContentQuery.data.value?.length) {
modpackContentModal.value?.show(modpackContentQuery.data.value)
return
}
@@ -767,12 +907,12 @@ async function handleModpackContent() {
}
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
if (!props.instance?.path) return
if (!props.instance?.id) return
const contentItems = await queryClient
.fetchQuery({
queryKey: modpackContentQueryKey.value,
queryFn: () => get_linked_modpack_content(props.instance.path, cacheBehaviour),
queryFn: () => get_linked_modpack_content(props.instance.id, cacheBehaviour),
})
.catch(handleError)
@@ -786,8 +926,17 @@ async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
await refreshModpackContentItems(cacheBehaviour)
}
watch(
() => installRevisionByInstance.value.get(props.instance.id) ?? 0,
async (revision) => {
if (revision <= handledInstallRevision.value) return
handledInstallRevision.value = revision
await refreshContentState('must_revalidate')
},
)
async function handleModpackUpdate() {
if (!props.instance?.linked_data?.project_id) return
if (!props.instance?.link?.project_id) return
const requestId = beginUpdateRequest()
@@ -800,7 +949,7 @@ async function handleModpackUpdate() {
await nextTick()
const initialVersionId =
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined
linkedModpackUpdateVersionId.value ?? props.instance?.link?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
@@ -809,11 +958,11 @@ async function handleModpackUpdate() {
linkedModpackVersion: linkedModpackVersion.value,
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
instance: {
path: props.instance.path,
path: props.instance.id,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
link: props.instance.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
@@ -829,10 +978,7 @@ async function handleModpackUpdate() {
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(
props.instance.linked_data.project_id,
initialVersionId,
)
const versions = await getUpdaterProjectVersions(props.instance.link.project_id, initialVersionId)
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
@@ -857,7 +1003,7 @@ async function handleModpackUpdate() {
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: props.instance.linked_data.version_id,
currentLinkedVersionId: props.instance.link.version_id,
})
updatingProjectVersions.value = versions
@@ -909,7 +1055,7 @@ function resetUpdateState() {
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = props.instance?.linked_data?.version_id
const currentVersionId = props.instance?.link?.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
@@ -918,7 +1064,7 @@ async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
if (!shouldShowWarning) {
if (skipNonEssentialWarnings.value || !shouldShowWarning) {
await handleModpackUpdateConfirm()
return
}
@@ -927,7 +1073,7 @@ async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.
}
async function handleModpackUpdateConfirm() {
if (!pendingModpackUpdateVersion.value || !props.instance?.path) return
if (!pendingModpackUpdateVersion.value || !props.instance?.id) return
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
@@ -935,7 +1081,7 @@ async function handleModpackUpdateConfirm() {
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(props.instance.path, version.id)
await update_managed_modrinth_version(props.instance.id, version.id)
await initProjects()
} finally {
isModpackUpdating.value = false
@@ -973,15 +1119,16 @@ async function handleModalUpdate(
}
}
async function unpairProfile() {
await edit(props.instance.path, {
linked_data: null as unknown as undefined,
async function unpairInstance() {
await edit(props.instance.id, {
link: null as unknown as undefined,
})
linkedModpackProject.value = null
linkedModpackVersion.value = null
linkedModpackOwner.value = null
linkedModpackHasUpdate.value = false
linkedModpackUpdateVersionId.value = null
localImportedModpackUnlinked.value = true
await initProjects()
}
@@ -1025,7 +1172,7 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
options.push({
id: formatMessage(commonMessages.showFileButton),
icon: FolderOpenIcon,
action: () => highlightModInProfile(props.instance.path, item.file_path),
action: () => highlightModInInstance(props.instance.id, item.file_path),
})
if (item.project?.slug) {
@@ -1046,23 +1193,24 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (!props.instance) return
const contentData = await loadInstanceContentData(
props.instance.path,
cacheBehaviour,
handleError,
)
const contentData = await loadInstanceContentData(props.instance.id, cacheBehaviour, handleError)
applyContentData(contentData)
}
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== props.instance.path) return false
if (contentData.path !== props.instance.id) {
return false
}
if (!contentData.contentItems) {
loading.value = false
return true
}
projects.value = contentData.contentItems
projects.value = contentData.contentItems.map((item) => ({
...item,
has_update: canUpdateProject(item),
}))
if (contentData.modpack) {
linkedModpackProject.value = contentData.modpack.project
@@ -1086,15 +1234,18 @@ function applyContentData(contentData: InstanceContentData) {
provideAppBackup({
async createBackup() {
const allProfiles = await list()
const allInstances = await list()
const prefix = `${props.instance.name} - Backup #`
const existingNums = allProfiles
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(props.instance.path)
await edit(newPath, { name: `${prefix}${nextNum}` })
const job = await install_duplicate_instance(props.instance.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
},
})
@@ -1109,53 +1260,68 @@ provideContentManager({
items: mergedProjects,
loading,
error: ref(null),
modpack: computed(() =>
linkedModpackProject.value
? {
project: linkedModpackProject.value,
projectLink: {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`,
query: { i: props.instance.path },
},
version: linkedModpackVersion.value ?? undefined,
versionLink:
linkedModpackProject.value && linkedModpackVersion.value
? {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`,
query: { i: props.instance.path },
}
: undefined,
owner: linkedModpackOwner.value
modpack: computed(() => {
if (linkedModpackProject.value) {
return {
project: linkedModpackProject.value,
projectLink: {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`,
query: { i: props.instance.id },
},
version: linkedModpackVersion.value ?? undefined,
versionLink:
linkedModpackProject.value && linkedModpackVersion.value
? {
...linkedModpackOwner.value,
link: () =>
openUrl(
`https://modrinth.com/${linkedModpackOwner.value!.type}/${linkedModpackOwner.value!.id}`,
),
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`,
query: { i: props.instance.id },
}
: undefined,
categories: linkedModpackCategories.value,
hasUpdate: linkedModpackHasUpdate.value,
disabled: isModpackUpdating.value,
disabledText: isModpackUpdating.value
? formatMessage(commonMessages.updatingLabel)
: formatMessage(commonMessages.installingLabel),
}
: null,
),
owner: linkedModpackOwner.value
? {
...linkedModpackOwner.value,
link: () =>
openUrl(
`https://modrinth.com/${linkedModpackOwner.value!.type}/${linkedModpackOwner.value!.id}`,
),
}
: undefined,
categories: linkedModpackCategories.value,
hasUpdate: linkedModpackHasUpdate.value,
disabled: isModpackUpdating.value,
disabledText: isModpackUpdating.value
? formatMessage(commonMessages.updatingLabel)
: formatMessage(commonMessages.installingLabel),
}
}
if (localImportedModpackProject.value) {
return {
project: localImportedModpackProject.value,
categories: [],
hasUpdate: false,
disabled: isModpackUpdating.value,
disabledText: isModpackUpdating.value
? formatMessage(commonMessages.updatingLabel)
: formatMessage(commonMessages.installingLabel),
}
}
return null
}),
isPackLocked,
isBusy: isInstanceBusy,
isBulkOperating,
skipNonEssentialWarnings,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item))).then(
() => {},
),
Promise.all(
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(items.filter((item) => item.enabled).map((item) => toggleDisableMod(item))).then(
() => {},
),
Promise.all(
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
@@ -1165,10 +1331,11 @@ provideContentManager({
uploadFiles: handleUploadFiles,
hasUpdateSupport: true,
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairProfile,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
@@ -1184,9 +1351,7 @@ provideContentManager({
title: item.file_name.replace('.disabled', ''),
icon_url: null,
},
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.path } }
: undefined,
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
version: item.version ?? {
id: item.file_name,
version_number: formatMessage(commonMessages.unknownLabel),
@@ -1196,7 +1361,6 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.path },
}
: undefined,
owner: item.owner
@@ -1208,7 +1372,7 @@ provideContentManager({
enabled: item.enabled,
installing: item.installing,
}),
filterPersistKey: props.instance.path,
filterPersistKey: props.instance.id,
})
type UnlistenFn = () => void
@@ -1216,7 +1380,17 @@ type UnlistenFn = () => void
const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
function getInstallRevision() {
return installRevisionByInstance.value.get(props.instance.id) ?? 0
}
function loadInitialContent() {
const installRevision = getInstallRevision()
if (installRevision > handledInstallRevision.value) {
handledInstallRevision.value = installRevision
return initProjects('must_revalidate')
}
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
return Promise.resolve()
}
@@ -1241,7 +1415,7 @@ const removeBeforeEach = router.beforeEach(() => {
let isUnmounted = false
let unlistenDragDrop: UnlistenFn | null = null
let unlistenProfiles: UnlistenFn | null = null
let unlistenInstances: UnlistenFn | null = null
onMounted(() => {
void getCurrentWebview()
@@ -1250,7 +1424,7 @@ onMounted(() => {
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
await add_project_from_path(props.instance.id, file).catch(handleError)
}
await initProjects()
})
@@ -1264,10 +1438,10 @@ onMounted(() => {
})
.catch(handleError)
void profile_listener(async (event: { event: string; profile_path_id: string }) => {
void instance_listener(async (event: { event: string; instance_id: string }) => {
if (
props.instance &&
event.profile_path_id === props.instance.path &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing' &&
!isBulkOperating.value
@@ -1281,7 +1455,7 @@ onMounted(() => {
return
}
unlistenProfiles = unlisten
unlistenInstances = unlisten
})
.catch(handleError)
})
@@ -1290,7 +1464,7 @@ watch(
() => props.instance?.install_stage,
async (newStage, oldStage) => {
if (oldStage !== 'installed' && newStage === 'installed') {
await initProjects('must_revalidate')
await refreshContentState('must_revalidate')
} else if (oldStage === 'not_installed' && newStage === 'pack_installing') {
await initProjects()
}
@@ -1298,16 +1472,16 @@ watch(
)
watch(
() => props.instance?.linked_data,
async (newLinkedData, oldLinkedData) => {
if (oldLinkedData && !newLinkedData) {
() => props.instance?.link,
async (newInstanceLink, oldInstanceLink) => {
if (oldInstanceLink && !newInstanceLink) {
await initProjects('must_revalidate')
}
},
)
watch(
() => props.instance?.preferred_update_channel,
() => props.instance?.update_channel,
async (newValue, oldValue) => {
if (newValue !== oldValue) {
await initProjects('must_revalidate')
@@ -1319,6 +1493,6 @@ onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
unlistenDragDrop?.()
unlistenProfiles?.()
unlistenInstances?.()
})
</script>
+50 -112
View File
@@ -13,29 +13,10 @@
/>
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
<ConfirmModalWrapper
ref="removeServerModal"
:title="
formatMessage(messages.removeServerTitle, {
name: serverToRemove?.name ?? formatMessage(messages.thisServer),
})
"
:description="
serverToRemove?.address === serverToRemove?.name
? formatMessage(messages.removeServerDescription, { name: serverToRemove?.name })
: formatMessage(messages.removeServerDescriptionWithAddress, {
name: serverToRemove?.name,
address: serverToRemove?.address,
})
"
:markdown="false"
@proceed="proceedRemoveServer"
/>
<ConfirmModalWrapper
ref="deleteWorldModal"
:title="formatMessage(messages.deleteWorldTitle)"
:description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
@proceed="proceedDeleteWorld"
<ConfirmRemoveWorldModal
ref="removeWorldModal"
:world="worldToRemove"
@confirm="proceedRemoveWorld"
/>
<ReadyTransition :pending="worldsReadyPending">
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
@@ -64,7 +45,7 @@
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
@@ -117,6 +98,7 @@
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
:shortcut-instance-id="instance.id"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@@ -129,7 +111,7 @@
: editServerModal?.show(world)
"
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.id, world.path)"
/>
</div>
</div>
@@ -150,7 +132,7 @@
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
@@ -182,32 +164,33 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events'
import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import {
delete_world,
get_profile_protocol_version,
get_instance_protocol_version,
getServerDomainKey,
getWorldIdentifier,
handleDefaultProfileUpdateEvent,
handleDefaultInstanceUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
type InstanceEvent,
normalizeServerAddress,
type ProfileEvent,
type ProtocolVersion,
refreshServerData,
refreshServers,
refreshWorld,
refreshWorlds,
remove_server_from_profile,
remove_server_from_instance,
resolveManagedServerWorld,
type ServerData,
type ServerWorld,
@@ -220,32 +203,8 @@ import {
} from '@/helpers/worlds.ts'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
const messages = defineMessages({
removeServerTitle: {
id: 'app.instance.worlds.remove-server-title',
defaultMessage: 'Are you sure you want to remove {name}?',
},
removeServerDescription: {
id: 'app.instance.worlds.remove-server-description',
defaultMessage:
"'{name}' will be removed from your list, including in-game, and there will be no way to recover it.",
},
removeServerDescriptionWithAddress: {
id: 'app.instance.worlds.remove-server-description-with-address',
defaultMessage:
"'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it.",
},
deleteWorldTitle: {
id: 'app.instance.worlds.delete-world-title',
defaultMessage: 'Are you sure you want to permanently delete this world?',
},
deleteWorldDescription: {
id: 'app.instance.worlds.delete-world-description',
defaultMessage:
"'{name}' will be **permanently deleted**, and there will be no way to recover it.",
},
searchWorldsPlaceholder: {
id: 'app.instance.worlds.search-worlds-placeholder',
defaultMessage: 'Search {count} worlds...',
@@ -266,10 +225,6 @@ const messages = defineMessages({
id: 'app.instance.worlds.no-worlds-description',
defaultMessage: 'Add a server or browse to get started',
},
thisServer: {
id: 'app.instance.worlds.this-server',
defaultMessage: 'this server',
},
vanillaFilter: {
id: 'app.instance.worlds.filter-vanilla',
defaultMessage: 'Vanilla',
@@ -297,11 +252,9 @@ const router = useRouter()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
const serverToRemove = ref<ServerWorld>()
const worldToDelete = ref<SingleplayerWorld>()
const worldToRemove = ref<World | null>(null)
const emit = defineEmits<{
(event: 'play', world: World): void
@@ -357,8 +310,8 @@ const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worldsQuery = useQuery({
queryKey: computed(() => ['worlds', instance.value.path]),
queryFn: () => refreshWorlds(instance.value.path),
queryKey: computed(() => ['worlds', instance.value.id]),
queryFn: () => refreshWorlds(instance.value.id),
staleTime: 30_000,
})
@@ -406,12 +359,12 @@ function isManagedServerWorld(world: World): world is ServerWorld {
async function refreshManagedServerMetadata() {
await ensureManagedServerWorldExists(
instance.value.path,
instance.value.id,
managedServerName.value,
managedServerAddress.value,
)
const projectId = instance.value.linked_data?.project_id
const projectId = instance.value.link?.project_id
if (!projectId) {
managedServerName.value = null
managedServerAddress.value = null
@@ -441,7 +394,7 @@ async function refreshManagedServerMetadata() {
managedServerAddress.value = serverAddress
} catch (err) {
console.error(
`Failed to resolve managed server metadata for profile: ${instance.value.path}`,
`Failed to resolve managed server metadata for instance: ${instance.value.id}`,
err,
)
managedServerName.value = null
@@ -450,22 +403,22 @@ async function refreshManagedServerMetadata() {
}
watch(
() => instance.value.linked_data?.project_id,
() => instance.value.link?.project_id,
async () => {
await refreshManagedServerMetadata()
},
{ immediate: true },
)
let unlistenProfile: (() => void) | null = null
let unlistenInstance: (() => void) | null = null
let worldsTabAlive = true
async function initWorldsTab() {
const [_unlistenProfile, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
const [_unlistenInstance, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
instance_listener(async (e: InstanceEvent) => {
if (e.instance_id !== instance.value.id) return
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
console.info(`Handling instance event '${e.event}' for instance: ${e.instance_id}`)
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
@@ -474,18 +427,18 @@ async function initWorldsTab() {
await refreshAllWorlds()
}
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
await handleDefaultInstanceUpdateEvent(worlds.value, instance.value.id, e)
}),
get_profile_protocol_version(instance.value.path).catch(() => null),
get_instance_protocol_version(instance.value.id).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
if (!worldsTabAlive) {
_unlistenProfile()
_unlistenInstance()
return
}
unlistenProfile = _unlistenProfile
unlistenInstance = _unlistenInstance
protocolVersion.value = resolvedProtocolVersion
gameVersions.value = resolvedGameVersions
}
@@ -508,7 +461,7 @@ async function refreshAllWorlds() {
}
refreshingAll.value = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.path] })
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
refreshingAll.value = false
}
@@ -534,7 +487,7 @@ async function editServer(server: ServerWorld) {
}
async function removeServer(server: ServerWorld) {
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
await remove_server_from_instance(instance.value.id, server.index).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
let serverIdx = 0
for (const w of worlds.value) {
@@ -559,12 +512,12 @@ async function editWorld(path: string, name: string, removeIcon: boolean) {
}
async function deleteWorld(world: SingleplayerWorld) {
await delete_world(instance.value.path, world.path).catch(handleError)
await delete_world(instance.value.id, world.path).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
}
function handleJoinError(err: Error) {
handleSevereError(err, { profilePath: instance.value.path })
handleSevereError(err, { instanceId: instance.value.id })
startingInstance.value = false
worldPlaying.value = undefined
}
@@ -574,7 +527,7 @@ async function joinWorld(world: World) {
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
const managedProjectId = instance.value.linked_data?.project_id
const managedProjectId = instance.value.link?.project_id
if (managedProjectId && isManagedServerWorld(world)) {
await playServerProject(managedProjectId).catch(handleJoinError)
trackEvent('InstanceStart', {
@@ -585,14 +538,14 @@ async function joinWorld(world: World) {
startingInstance.value = false
return
}
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
}
play(world)
startingInstance.value = false
@@ -607,7 +560,7 @@ watch(
setTimeout(async () => {
for (const world of worlds.value) {
if (world.type === 'singleplayer' && world.locked) {
await refreshWorld(worlds.value, instance.value.path, world.path)
await refreshWorld(worlds.value, instance.value.id, world.path)
}
}
}, 1000)
@@ -744,37 +697,22 @@ const filteredWorlds = computed(() =>
const highlightedWorld = ref(route.query.highlight)
function promptToRemoveWorld(world: World): boolean {
worldToRemove.value = world
removeWorldModal.value?.show()
return !!removeWorldModal.value
}
async function proceedRemoveWorld(world: World) {
if (world.type === 'server') {
serverToRemove.value = world
removeServerModal.value?.show()
return !!removeServerModal.value
await removeServer(world)
} else {
worldToDelete.value = world
deleteWorldModal.value?.show()
return !!deleteWorldModal.value
await deleteWorld(world)
}
}
async function proceedRemoveServer() {
if (!serverToRemove.value) {
handleError(new Error(`Error removing server, no server marked for removal.`))
return
}
await removeServer(serverToRemove.value)
serverToRemove.value = undefined
}
async function proceedDeleteWorld() {
if (!worldToDelete.value) {
handleError(new Error(`Error deleting world, no world marked for removal.`))
return
}
await deleteWorld(worldToDelete.value)
worldToDelete.value = undefined
worldToRemove.value = null
}
onBeforeUnmount(() => {
worldsTabAlive = false
unlistenProfile?.()
unlistenInstance?.()
})
</script>