refactor: use DI for instance page + subpages (#6987)

* refactor: use DI for instance page + subpages

* fix: qa

* refactor: layout.vue bring up a layer

* refactor: rename folders

* import order

* refactor: move settings into instance page

* fix: lint

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-04 16:52:00 +00:00
committed by GitHub
co-authored by tdgao
parent 99377f8436
commit fe0c97190a
42 changed files with 1009 additions and 1142 deletions
+2 -1
View File
@@ -120,6 +120,7 @@ import {
} from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n from '@/i18n.config'
import { instanceKeys } from '@/pages/instance/query-options'
import {
appUpdateState,
downloadAvailableAppUpdate,
@@ -242,7 +243,7 @@ const { data: authenticatedModrinthUser } = useQuery({
retry: false,
})
useQuery({
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]),
queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
retry: false,
@@ -9,6 +9,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
@@ -123,7 +124,7 @@ const getInstances = async () => {
const instances = await list().catch(handleError)
for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
}
allInstances.value = instances.sort((a, b) => {
@@ -15,6 +15,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
@@ -67,7 +68,7 @@ async function addServer(instance) {
try {
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.id) })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
@@ -17,6 +17,7 @@ import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
import { instanceKeys } from '@/pages/instance/query-options'
interface BrowseServerInstance {
id: string
@@ -38,7 +39,7 @@ interface ContextMenuOptionClick {
}
export interface UseAppServerBrowseOptions {
instance: Ref<BrowseServerInstance | null>
instance: Readonly<Ref<BrowseServerInstance | null>>
isFromWorlds: ComputedRef<boolean>
allInstalledIds: ComputedRef<Set<string>>
newlyInstalled: Ref<string[]>
@@ -131,7 +132,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
project.minecraft_java_server?.content?.kind,
)
options.newlyInstalled.value.push(project.project_id)
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instanceId) })
} catch (error) {
options.handleError(error)
}
@@ -51,7 +51,8 @@ export async function loadInstanceContentData(
}
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
onError?.(error as Error)
if (!onError) throw error
onError(error as Error)
return null
}
+64 -47
View File
@@ -32,7 +32,7 @@ import {
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
@@ -41,15 +41,9 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import {
get_project,
get_project_v3,
get_search_results_v3,
get_version_many,
} from '@/helpers/cache.js'
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events.js'
import {
get as getInstance,
get_installed_project_ids as getInstalledProjectIds,
list as listInstances,
} from '@/helpers/instance'
@@ -57,6 +51,11 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import {
instanceDetailQueryOptions,
instanceKeys,
instanceLinkedProjectQueryOptions,
} from '@/pages/instance/query-options'
import {
type BreadcrumbDefinition,
useBreadcrumb,
@@ -154,30 +153,29 @@ const {
markServerProjectInstalled,
} = serverInstallContent
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const initialInstanceId = String(route.query.i ?? '')
const instance: Ref<Instance | null> = ref(
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
const initialInstanceId = computed(() => String(route.query.i ?? ''))
const instanceQuery = useQuery(
computed(() => ({
...instanceDetailQueryOptions(initialInstanceId.value),
enabled: !!initialInstanceId.value,
})),
)
const instance = computed(() => instanceQuery.data.value ?? null)
const linkedInstanceProjectId = computed(() => instance.value?.link?.project_id ?? '')
const linkedInstanceProjectQuery = useQuery(
computed(() => ({
...instanceLinkedProjectQueryOptions(linkedInstanceProjectId.value),
enabled: !!linkedInstanceProjectId.value,
})),
)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(route.query.ai === 'true')
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
const isServerInstance = computed(
() => linkedInstanceProjectQuery.data.value?.minecraft_server != null,
)
const instanceBreadcrumb = route.query.i
? useBreadcrumb({
@@ -291,7 +289,13 @@ await initInstanceContext()
async function refreshInstalledProjectIds() {
if (!route.query.i) {
const instances = await listInstances().catch(handleError)
const instances = await queryClient
.fetchQuery({
queryKey: [...instanceKeys.all, 'installed-project-ids'],
queryFn: listInstances,
staleTime: 0,
})
.catch(handleError)
if (!instances) return
const ids = instances
@@ -303,7 +307,14 @@ async function refreshInstalledProjectIds() {
}
if (route.query.from === 'worlds') {
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
const targetInstanceId = route.query.i as string
const worlds = await queryClient
.fetchQuery({
queryKey: instanceKeys.installedProjectIds(targetInstanceId, 'worlds'),
queryFn: () => get_instance_worlds(targetInstanceId),
staleTime: 0,
})
.catch(handleError)
if (!worlds) return
const serverProjectIds = worlds
@@ -314,7 +325,14 @@ async function refreshInstalledProjectIds() {
return
}
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
const targetInstanceId = route.query.i as string
const ids = await queryClient
.fetchQuery({
queryKey: instanceKeys.installedProjectIds(targetInstanceId, 'content'),
queryFn: () => getInstalledProjectIds(targetInstanceId),
staleTime: 0,
})
.catch(handleError)
if (!ids) return
debugLog('installedProjectIds loaded', { count: ids.length })
@@ -329,11 +347,13 @@ async function initInstanceContext() {
queryWid: route.query.wid,
queryFrom: route.query.from,
})
await initServerContext()
await refreshInstalledProjectIds()
await Promise.all([
initServerContext(),
refreshInstalledProjectIds(),
route.query.i ? instanceQuery.suspense().catch(handleError) : Promise.resolve(),
])
if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
debugLog('instance loaded', {
name: instance.value?.name,
loader: instance.value?.loader,
@@ -341,15 +361,7 @@ async function initInstanceContext() {
})
if (instance.value?.link?.project_id) {
debugLog('checking linked project for server status', instance.value.link.project_id)
const projectV3 = await get_project_v3(
instance.value.link.project_id,
'must_revalidate',
).catch(handleError)
if (projectV3?.minecraft_server != null) {
debugLog('instance is a server instance')
isServerInstance.value = true
}
await linkedInstanceProjectQuery.suspense().catch(handleError)
}
}
}
@@ -577,16 +589,12 @@ const messages = defineMessages({
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
function resetInstanceContext() {
if (!instance.value) return
debugLog('instance context removed, resetting')
instance.value = null
installedProjectIds.value = null
instanceHideInstalled.value = false
newlyInstalled.value = []
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
browseBreadcrumb.reset()
void refreshInstalledProjectIds()
}
@@ -611,9 +619,18 @@ watch(
watch(
() => route.query.i,
(instanceId) => {
if (!instanceId && route.path.startsWith('/browse')) {
async (nextInstanceId, previousInstanceId) => {
if (!route.path.startsWith('/browse') || nextInstanceId === previousInstanceId) return
if (!nextInstanceId) {
resetInstanceContext()
return
}
installedProjectIds.value = null
hiddenInstanceProjectIdsInitialized.value = false
await Promise.all([instanceQuery.suspense().catch(handleError), refreshInstalledProjectIds()])
if (instance.value?.link?.project_id) {
await linkedInstanceProjectQuery.suspense().catch(handleError)
}
},
)
@@ -1,13 +0,0 @@
<template>{{ instance.name }} overview</template>
<script setup lang="ts">
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
installed: boolean
}>()
</script>
@@ -36,11 +36,11 @@ import { computed, ref, watch } from 'vue'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './instance-admonitions-shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
import InstanceAdmonitionsSharedInstanceStale from './shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types.ts'
defineOptions({
inheritAttrs: false,
@@ -42,7 +42,7 @@ import { computed, ref } from 'vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import { instanceAdmonitionsMessages as messages } from './messages'
defineProps<{
instance: GameInstance
@@ -20,7 +20,7 @@
import { DownloadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import { instanceAdmonitionsMessages as messages } from './messages'
defineProps<{
instanceName: string
@@ -29,7 +29,7 @@ import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import { instanceAdmonitionsMessages as messages } from './messages'
import type { SharedInstanceRole } from './types'
const props = defineProps<{
@@ -21,9 +21,9 @@ import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInsta
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, list, remove } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
import type { GameInstance } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -10,9 +10,9 @@ import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, Hooks } from '../../../helpers/types'
import type { AppSettings, Hooks } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -22,19 +22,19 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, nextTick, ref, watch } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '@/helpers/types'
import type { GameInstance } from '../../../helpers/types'
import GeneralSettings from './general-settings.vue'
import HooksSettings from './hooks-settings.vue'
import InstallationSettings from './installation-settings.vue'
import { provideInstanceSettings } from './instance-settings-context.ts'
import JavaSettings from './java-settings.vue'
import SharingSettings from './sharing-settings.vue'
import WindowSettings from './window-settings.vue'
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
@@ -15,7 +15,6 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
@@ -34,10 +33,12 @@ import {
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../helpers/types'
import type { Manifest } from '../../../../helpers/types'
import { instanceKeys } from '../../query-options.ts'
import { injectInstanceSettings } from './instance-settings-context.ts'
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
const { handleError } = injectNotificationManager()
const filePicker = injectFilePicker()
@@ -148,7 +149,9 @@ async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({
queryKey: instanceKeys.sharedMembers(instance.value.id),
})
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
@@ -25,9 +25,9 @@ import useJavaTest from '@/composables/useJavaTest'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -94,8 +94,6 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { config } from '@/config'
import {
get_shared_instance_invites,
@@ -104,7 +102,11 @@ import {
unpublish_shared_instance,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { instanceKeys } from '../../query-options.ts'
import ConfirmRevokeSharedInstanceInviteModal from './confirm-revoke-shared-instance-invite-modal.vue'
import { injectInstanceSettings } from './instance-settings-context.ts'
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { notifySharedInstanceError } = useSharedInstanceErrors()
@@ -193,7 +195,7 @@ async function unpublishSharedInstance() {
unpublishing.value = true
try {
await unpublish_shared_instance(instance.value.id)
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
queryClient.setQueryData(instanceKeys.sharedMembers(instance.value.id), [])
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
@@ -11,9 +11,9 @@ import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -19,13 +19,11 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined"
:enable-toggle="!isServerInstance && !isSharedMember && !isQuarantined"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="
props.isServerInstance || isSharedMember || isQuarantined
? undefined
: handleSwitchVersion
isServerInstance || isSharedMember || isQuarantined ? undefined : handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@@ -146,13 +144,15 @@ import {
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import type { CacheBehaviour } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
import { injectInstancePage } from '../instance-context'
import { instanceContentQueryOptions, instanceKeys } from '../query-options'
const messages = defineMessages({
shareTitle: {
id: 'app.instance.mods.share-title',
@@ -218,13 +218,11 @@ const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
const props = defineProps<{
instance: GameInstance
isServerInstance?: boolean
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const instancePage = injectInstancePage()
const instance = instancePage.instance
const isServerInstance = instancePage.isServerInstance
const openSettings = () => instancePage.openSettings(1)
const managedContentPolicy = useManagedContentPolicy(computed(() => instance.value))
const {
isManagedModpack: isSharedMember,
isQuarantined,
@@ -232,18 +230,20 @@ const {
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
}
const loading = ref(!hasPreloadedContent(props.preloadedContent))
const contentQuery = useQuery(
computed(() => ({
...instanceContentQueryOptions(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
})),
)
const loading = ref(contentQuery.data.value === undefined)
const projects = ref<ContentItem[]>([])
const installingBuffer = ref<ContentItem[]>([])
const handledInstallRevision = ref(0)
watch(
() => installingItems.value.get(props.instance.id),
() => installingItems.value.get(instance.value.id),
(items) => {
if (items && items.length > 0) {
installingBuffer.value = [...items]
@@ -261,7 +261,7 @@ watch(projects, (newProjects) => {
})
const mergedProjects = computed<ContentItem[]>(() => {
const active = installingItems.value.get(props.instance.id)
const active = installingItems.value.get(instance.value.id)
const pending = active ?? installingBuffer.value
if (pending.length === 0) return projects.value
const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean))
@@ -276,7 +276,7 @@ const mergedProjects = computed<ContentItem[]>(() => {
})
watch(
() => installFailureRevisionByInstance.value.get(props.instance.id) ?? 0,
() => installFailureRevisionByInstance.value.get(instance.value.id) ?? 0,
(revision, previousRevision) => {
if (revision === previousRevision) return
installingBuffer.value = []
@@ -292,14 +292,14 @@ const linkedModpackUpdateVersionId = ref<string | null>(null)
const localImportedModpackUnlinked = ref(false)
const localImportedModpackProject = computed<ContentModpackCardProject | null>(() => {
const link = props.instance.link
const link = instance.value.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,
id: link.filename ?? instance.value.id,
slug: link.filename ?? instance.value.id,
title: link.name ?? instance.value.name,
icon_url: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
description: '',
filename: link.filename ?? undefined,
}
@@ -310,7 +310,7 @@ const displayedModpackProject = computed(
)
watch(
() => props.instance.link,
() => instance.value.link,
() => {
localImportedModpackUnlinked.value = false
},
@@ -318,12 +318,12 @@ watch(
const isModpackUpdating = ref(false)
const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isInstanceBusy = computed(() => instance.value?.install_stage !== 'installed')
const isPackLocked = computed(
() =>
props.instance.quarantined ||
props.instance?.link?.type === 'modrinth_modpack' ||
props.instance?.link?.type === 'server_project_modpack',
instance.value.quarantined ||
instance.value?.link?.type === 'modrinth_modpack' ||
instance.value?.link?.type === 'server_project_modpack',
)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
@@ -337,15 +337,15 @@ const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal>
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQueryKey = computed(() => instanceKeys.linkedContent(instance.value.id))
const modpackContentQuery = useQuery({
queryKey: modpackContentQueryKey,
queryFn: () => get_linked_modpack_content(props.instance.id),
queryFn: () => get_linked_modpack_content(instance.value.id),
enabled: computed(
() =>
!!props.instance?.id &&
!!props.instance?.link &&
props.instance.install_stage === 'installed',
!!instance.value?.id &&
!!instance.value?.link &&
instance.value.install_stage === 'installed',
),
})
@@ -523,15 +523,12 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st
}
async function handleBrowseContent() {
if (!props.instance || props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
})
if (!instance.value || instance.value.quarantined) return
await instancePage.browseContent(instance.value.loader === 'vanilla' ? 'resourcepack' : 'mod')
}
async function handleUploadFiles() {
if (!props.instance || props.instance.quarantined) return
if (!instance.value || instance.value.quarantined) return
const files = await open({ multiple: true })
if (!files) return
const selectedFiles: Array<{ path: string; filename: string }> = []
@@ -566,7 +563,7 @@ async function handleUploadFiles() {
await Promise.all(
confirmedFiles.map(async ({ path, filename }) => {
try {
const installedPath = await add_project_from_path(props.instance.id, path)
const installedPath = await add_project_from_path(instance.value.id, path)
return { filename, installedPath }
} catch (error) {
handleError(error as Error)
@@ -637,7 +634,7 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
const originalFilePath = mod.file_path
try {
const newPath = await toggle_disable_project(props.instance.id, mod.file_path, desiredEnabled)
const newPath = await toggle_disable_project(instance.value.id, mod.file_path, desiredEnabled)
const newFileName = fileNameFromPath(newPath)
const enabled = !newPath.endsWith('.disabled')
mod.file_path = newPath
@@ -655,8 +652,8 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
})
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -678,12 +675,12 @@ async function removeMod(mod: ContentItem) {
try {
const removedPath = mod.file_path
await remove_project(props.instance.id, removedPath)
await remove_project(instance.value.id, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -709,7 +706,7 @@ function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item
}
async function getDeleteDependencyWarning(items: ContentItem[]) {
if (props.isServerInstance) return null
if (isServerInstance.value) return null
const deletingIds = new Set(items.map(getContentItemId))
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
@@ -787,12 +784,12 @@ async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus)
waiting: true,
})
unlisten = await instance_bulk_update_progress_listener((progress) => {
if (progress.instanceId !== props.instance.id) return
if (progress.instanceId !== instance.value.id) return
onProgress(formatBulkUpdateProgress(progress))
})
}
await update_all(props.instance.id)
await update_all(instance.value.id)
await refreshContentState('must_revalidate')
} catch (err) {
handleError(err as Error)
@@ -810,14 +807,14 @@ async function updateProject(mod: ContentItem) {
try {
const updateVersionId = mod.update_version_id!
await switch_project_version_with_dependencies(
props.instance.id,
instance.value.id,
mod.file_path,
updateVersionId,
)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -840,11 +837,11 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
const oldPath = mod.file_path
try {
await switch_project_version_with_dependencies(props.instance.id, oldPath, version.id)
await switch_project_version_with_dependencies(instance.value.id, oldPath, version.id)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -872,8 +869,8 @@ async function handleUpdate(id: string) {
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
instanceGameVersion: props.instance.game_version,
instanceLoader: props.instance.loader,
instanceGameVersion: instance.value.game_version,
instanceLoader: instance.value.loader,
})
updatingModpack.value = false
@@ -899,11 +896,11 @@ async function handleUpdate(id: string) {
updateVersionId: item.update_version_id,
},
instance: {
path: props.instance.id,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
link: props.instance.link,
path: instance.value.id,
name: instance.value.name,
gameVersion: instance.value.game_version,
loader: instance.value.loader,
link: instance.value.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
@@ -1027,7 +1024,7 @@ async function setModpackContentEnabled(items: ContentItem[], enabled: boolean)
}
async function handleModpackContent() {
if (!props.instance?.id) return
if (!instance.value?.id) return
if (modpackContentQuery.data.value?.length) {
modpackContentModal.value?.show(modpackContentQuery.data.value)
@@ -1047,12 +1044,12 @@ async function handleModpackContent() {
}
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
if (!props.instance?.id) return
if (!instance.value?.id) return
const contentItems = await queryClient
.fetchQuery({
queryKey: modpackContentQueryKey.value,
queryFn: () => get_linked_modpack_content(props.instance.id, cacheBehaviour),
queryFn: () => get_linked_modpack_content(instance.value.id, cacheBehaviour),
})
.catch(handleError)
@@ -1067,7 +1064,7 @@ async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
}
watch(
() => installRevisionByInstance.value.get(props.instance.id) ?? 0,
() => installRevisionByInstance.value.get(instance.value.id) ?? 0,
async (revision) => {
if (revision <= handledInstallRevision.value) return
handledInstallRevision.value = revision
@@ -1076,7 +1073,7 @@ watch(
)
async function handleModpackUpdate() {
if (!props.instance?.link?.project_id) return
if (!instance.value?.link?.project_id) return
const requestId = beginUpdateRequest()
@@ -1089,7 +1086,7 @@ async function handleModpackUpdate() {
await nextTick()
const initialVersionId =
linkedModpackUpdateVersionId.value ?? props.instance?.link?.version_id ?? undefined
linkedModpackUpdateVersionId.value ?? instance.value?.link?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
@@ -1098,11 +1095,11 @@ async function handleModpackUpdate() {
linkedModpackVersion: linkedModpackVersion.value,
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
instance: {
path: props.instance.id,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
link: props.instance.link,
path: instance.value.id,
name: instance.value.name,
gameVersion: instance.value.game_version,
loader: instance.value.loader,
link: instance.value.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
@@ -1118,7 +1115,7 @@ async function handleModpackUpdate() {
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(props.instance.link.project_id, initialVersionId)
const versions = await getUpdaterProjectVersions(instance.value.link.project_id, initialVersionId)
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
@@ -1143,7 +1140,7 @@ async function handleModpackUpdate() {
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: props.instance.link.version_id,
currentLinkedVersionId: instance.value.link.version_id,
})
updatingProjectVersions.value = versions
@@ -1195,14 +1192,14 @@ function resetUpdateState() {
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = props.instance?.link?.version_id
const currentVersionId = instance.value?.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)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
versionChangesGameVersion(selectedVersion, instance.value.game_version)
if (skipNonEssentialWarnings.value || !shouldShowWarning) {
await handleModpackUpdateConfirm()
@@ -1213,7 +1210,7 @@ async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.
}
async function handleModpackUpdateConfirm() {
if (!pendingModpackUpdateVersion.value || !props.instance?.id) return
if (!pendingModpackUpdateVersion.value || !instance.value?.id) return
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
@@ -1221,7 +1218,7 @@ async function handleModpackUpdateConfirm() {
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(props.instance.id, version.id)
await update_managed_modrinth_version(instance.value.id, version.id)
await initProjects()
} finally {
isModpackUpdating.value = false
@@ -1260,7 +1257,7 @@ async function handleModalUpdate(
}
async function unpairInstance() {
await edit(props.instance.id, {
await edit(instance.value.id, {
link: null as unknown as undefined,
})
linkedModpackProject.value = null
@@ -1312,7 +1309,7 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
options.push({
id: formatMessage(commonMessages.showFileButton),
icon: FolderOpenIcon,
action: () => highlightModInInstance(props.instance.id, item.file_path),
action: () => highlightModInInstance(instance.value.id, item.file_path),
})
if (item.project?.slug) {
@@ -1330,15 +1327,19 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
return options
}
async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (!props.instance) return
async function initProjects(cacheBehaviour?: CacheBehaviour, staleTime = 0) {
if (!instance.value) return
const contentData = await loadInstanceContentData(props.instance.id, cacheBehaviour, handleError)
const contentData = await queryClient.fetchQuery({
...instanceContentQueryOptions(instance.value.id),
queryFn: () => loadInstanceContentData(instance.value.id, cacheBehaviour, handleError),
staleTime,
})
applyContentData(contentData)
}
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== props.instance.id) {
if (contentData.path !== instance.value.id) {
return false
}
@@ -1372,8 +1373,6 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
loading,
@@ -1384,14 +1383,14 @@ provideContentManager({
project: linkedModpackProject.value,
projectLink: {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`,
query: { i: props.instance.id },
query: { i: instance.value.id },
},
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.id },
query: { i: instance.value.id },
}
: undefined,
owner: linkedModpackOwner.value
@@ -1459,12 +1458,12 @@ provideContentManager({
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack:
props.isServerInstance || isSharedMember.value || isQuarantined.value
isServerInstance.value || isSharedMember.value || isQuarantined.value
? undefined
: handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
openSettings: openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
shareItems: handleShareItems,
@@ -1478,7 +1477,7 @@ provideContentManager({
icon_url: null,
},
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
? { path: `/project/${item.project.id}`, query: { i: instance.value.id } }
: undefined,
version: item.version ?? {
id: item.file_name,
@@ -1489,7 +1488,7 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.id },
query: { i: instance.value.id },
}
: undefined,
owner: item.owner
@@ -1504,7 +1503,7 @@ provideContentManager({
hideSwitchVersion: !canMutateContent(item) || !item.project?.id || !item.version?.id,
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
filterPersistKey: instance.value.id,
})
type UnlistenFn = () => void
@@ -1513,7 +1512,7 @@ const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
function getInstallRevision() {
return installRevisionByInstance.value.get(props.instance.id) ?? 0
return installRevisionByInstance.value.get(instance.value.id) ?? 0
}
function loadInitialContent() {
@@ -1523,13 +1522,23 @@ function loadInitialContent() {
return initProjects('must_revalidate')
}
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
return Promise.resolve()
}
return initProjects()
return initProjects(undefined, 30_000)
}
watch(
contentQuery.data,
(data) => {
if (data) applyContentData(data)
},
{ immediate: true },
)
watch(contentQuery.error, (error) => {
if (error) {
loading.value = false
handleError(error)
}
})
async function restoreModpackContentModalState() {
if (!savedModalState) return
@@ -1552,11 +1561,11 @@ let unlistenInstances: UnlistenFn | null = null
onMounted(() => {
void getCurrentWebview()
.onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
if (event.payload.type !== 'drop' || !instance.value) return
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.id, file).catch(handleError)
await add_project_from_path(instance.value.id, file).catch(handleError)
}
await initProjects()
})
@@ -1572,10 +1581,10 @@ onMounted(() => {
void instance_listener(async (event: { event: string; instance_id: string }) => {
if (
props.instance &&
event.instance_id === props.instance.id &&
instance.value &&
event.instance_id === instance.value.id &&
event.event === 'synced' &&
props.instance.install_stage === 'installed' &&
instance.value.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
@@ -1593,7 +1602,7 @@ onMounted(() => {
})
watch(
() => props.instance?.install_stage,
() => instance.value?.install_stage,
async (newStage, oldStage) => {
if (oldStage !== 'installed' && newStage === 'installed') {
await refreshContentState('must_revalidate')
@@ -1604,7 +1613,7 @@ watch(
)
watch(
() => props.instance?.link,
() => instance.value?.link,
async (newInstanceLink, oldInstanceLink) => {
if (oldInstanceLink && !newInstanceLink) {
await initProjects('must_revalidate')
@@ -1613,7 +1622,7 @@ watch(
)
watch(
() => props.instance?.update_channel,
() => instance.value?.update_channel,
async (newValue, oldValue) => {
if (newValue !== oldValue) {
await initProjects('must_revalidate')
@@ -10,6 +10,7 @@ import {
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { invoke } from '@tauri-apps/api/core'
import {
mkdir,
@@ -22,21 +23,17 @@ import {
writeFile as writeFileBytes,
writeTextFile,
} from '@tauri-apps/plugin-fs'
import { onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { instance_listener } from '@/helpers/events'
import { get_full_path } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { highlightInFolder } from '@/helpers/utils'
const props = defineProps<{
instance: GameInstance
options: unknown
offline: boolean
playing: boolean
installed: boolean
isServerInstance: boolean
}>()
import { injectInstancePage } from '../instance-context'
import { instanceKeys } from '../query-options'
const instancePage = injectInstancePage()
const instanceId = instancePage.instanceId
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
@@ -53,7 +50,15 @@ const messages = defineMessages({
},
})
const instanceRoot = ref('')
const instanceRootQuery = useQuery(
computed(() => ({
queryKey: instanceKeys.rootPath(instancePage.instanceId.value),
queryFn: () => get_full_path(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
staleTime: Infinity,
})),
)
const instanceRoot = computed(() => instanceRootQuery.data.value ?? '')
const items = ref<FileItem[]>([])
/** True until the first directory read for the current instance path finishes (initial load only). */
const firstPaintPending = ref(true)
@@ -62,12 +67,7 @@ const error = ref<Error | null>(null)
const currentPath = ref('')
const editingFile = ref<EditingFile | null>(null)
debug('setup: start, instance.id =', props.instance.id)
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)
debug('setup: start, instance.id =', instanceId.value)
function resolvePath(relativePath: string): string {
return relativePath ? `${instanceRoot.value}/${relativePath}` : instanceRoot.value
@@ -113,21 +113,39 @@ async function listDirectory(dirPath: string): Promise<FileItem[]> {
return results.filter((item): item is FileItem => item !== null)
}
const directoryQuery = useQuery(
computed(() => ({
queryKey: instanceKeys.files(instancePage.instanceId.value, currentPath.value),
queryFn: () => listDirectory(currentPath.value),
enabled: !!instanceRoot.value,
staleTime: 30_000,
})),
)
watch(
directoryQuery.data,
(data) => {
if (!data) return
items.value = data
firstPaintPending.value = false
},
{ immediate: true },
)
watch(directoryQuery.isFetching, (fetching) => {
loading.value = fetching
})
watch(directoryQuery.error, (queryError) => {
error.value = queryError
if (queryError) items.value = []
})
await instanceRootQuery.suspense()
await directoryQuery.refetch()
firstPaintPending.value = false
async function refresh() {
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
loading.value = true
error.value = null
try {
items.value = await listDirectory(currentPath.value)
debug('refresh: success, items =', items.value.length)
} catch (e) {
debug('refresh: error =', e)
error.value = e instanceof Error ? e : new Error(String(e))
items.value = []
} finally {
loading.value = false
firstPaintPending.value = false
}
await directoryQuery.refetch()
}
function navigateTo(path: string) {
@@ -221,7 +239,7 @@ async function handleWriteFile(path: string, content: string) {
async function handleDownloadFile(path: string, _fileName: string) {
await invoke('plugin:files|file_save_as', {
instanceId: props.instance.id,
instanceId: instanceId.value,
filePath: path,
})
}
@@ -275,7 +293,7 @@ async function handleUploadFiles(files: File[]) {
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
try {
return await invoke('plugin:files|file_extract_zip', {
instanceId: props.instance.id,
instanceId: instanceId.value,
filePath: path,
overrideConflicts: override,
dryRun: dry,
@@ -293,7 +311,7 @@ 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') {
if (event.instance_id === instanceId.value && event.event === 'synced') {
debug('instance_listener: synced event matched, calling refresh')
await refresh()
}
@@ -305,16 +323,13 @@ onUnmounted(() => {
unlistenInstances()
})
watch(
() => props.instance.id,
async () => {
debug('watch instance.id: changed to', props.instance.id)
firstPaintPending.value = true
instanceRoot.value = await get_full_path(props.instance.id)
currentPath.value = ''
await refresh()
},
)
watch(instanceId, async () => {
debug('watch instance.id: changed to', instanceId.value)
firstPaintPending.value = true
currentPath.value = ''
await instanceRootQuery.refetch()
await refresh()
})
provideFileManager({
items,
@@ -1,9 +0,0 @@
import Files from './Files.vue'
import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
import Share from './share/index.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -0,0 +1,8 @@
import Content from './content/index.vue'
import Files from './files/index.vue'
import Index from './layout.vue'
import Logs from './logs/index.vue'
import Share from './share/index.vue'
import Worlds from './worlds/index.vue'
export { Content, Files, Index, Logs, Share, Worlds }
@@ -0,0 +1,27 @@
import type { Labrinth } from '@modrinth/api-client'
import { createContext } from '@modrinth/ui'
import type { ComputedRef, Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
export interface InstancePageContext {
readonly instanceId: ComputedRef<string>
readonly instance: ComputedRef<GameInstance>
readonly linkedProject: ComputedRef<Labrinth.Projects.v3.Project | undefined>
readonly isServerInstance: ComputedRef<boolean>
readonly offline: Readonly<Ref<boolean>>
readonly playing: ComputedRef<boolean>
readonly loading: Readonly<Ref<boolean>>
readonly stopping: Readonly<Ref<boolean>>
refreshInstance: () => Promise<void>
refreshPlayState: () => Promise<void>
play: (source: string) => Promise<void>
stop: (source: string) => Promise<void>
playServer: () => Promise<void>
openSettings: (tab?: number) => void
browseContent: (projectType?: string) => Promise<void>
browseServers: () => Promise<void>
}
export const [injectInstancePage, provideInstancePage] =
createContext<InstancePageContext>('InstancePage')
@@ -11,7 +11,7 @@
ref="settingsModal"
:instance="instance"
:offline="offline"
@unlinked="fetchInstance"
@unlinked="refreshInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
@@ -65,32 +65,20 @@
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
:shared-instance-update-available="showSharedInstanceUpdateAdmonition"
@published="fetchInstance"
@published="refreshInstance"
@delete="requestInstanceDeletion"
@review-update="reviewSharedInstanceUpdate"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense
:key="instance.id"
@pending="subpagePending = true"
@resolve="subpagePending = false"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
v-bind="contentSubpageProps"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
<component :is="Component" />
</Suspense>
</template>
</RouterView>
@@ -102,45 +90,24 @@
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
<template #hide_show><EyeIcon />Show/Hide unselected</template>
<template #update_all
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BoxesIcon,
CheckCircleIcon,
ClipboardCopyIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
HashIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
commonMessages,
injectAuth,
injectNotificationManager,
NavTabs,
useLoadingBarToken,
@@ -148,17 +115,15 @@ import {
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useOnline } from '@vueuse/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, type ComputedRef, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
@@ -168,7 +133,6 @@ import {
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import {
getSharedInstanceUnavailableReason,
@@ -178,69 +142,126 @@ import {
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get,
get_full_path,
kill,
remove,
run,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { get_full_path, kill, remove, run } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import type { ServerStatus } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
import InstanceAdmonitions from './components/admonitions/index.vue'
import InstancePageHeader from './components/page-header/index.vue'
import InstanceSettingsModal from './components/settings-modal/index.vue'
import { provideInstancePage } from './instance-context'
import {
instanceContentQueryOptions,
instanceDetailQueryOptions,
instanceKeys,
instanceLinkedProjectQueryOptions,
instanceProcessesQueryOptions,
} from './query-options'
import { createSharedInstanceContext, provideSharedInstance } from './shared-instance-context'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
const instance = ref<GameInstance | undefined>(
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
const online = useOnline()
const offline = computed(() => !online.value)
const instanceId = computed(() => String(route.params.id ?? ''))
const instanceQuery = useQuery(
computed(() => ({
...instanceDetailQueryOptions(instanceId.value),
enabled: !!instanceId.value,
})),
)
useQuery(
computed(() => ({
...instanceContentQueryOptions(instanceId.value, (error) => handleError(error)),
enabled: !!instanceId.value,
})),
)
const instance = computed(() => instanceQuery.data.value)
const linkedProjectId = computed(() => instance.value?.link?.project_id ?? '')
const linkedProjectQuery = useQuery(
computed(() => ({
...instanceLinkedProjectQueryOptions(linkedProjectId.value),
enabled: !!linkedProjectId.value && !offline.value,
})),
)
const linkedProjectV3 = computed(() => linkedProjectQuery.data.value ?? undefined)
const isServerInstance = computed(() => linkedProjectV3.value?.minecraft_server != null)
const processesQuery = useQuery(
computed(() => ({
...instanceProcessesQueryOptions(instanceId.value),
enabled: !!instanceId.value,
})),
)
const playing = computed(() => (processesQuery.data.value?.length ?? 0) > 0)
async function ensureCriticalContent(targetInstanceId: string) {
await queryClient.ensureQueryData(
instanceContentQueryOptions(targetInstanceId, (error) => handleError(error)),
)
}
async function ensureCriticalInstanceData(targetInstanceId: string) {
await Promise.all([
queryClient.ensureQueryData(instanceDetailQueryOptions(targetInstanceId)),
ensureCriticalContent(targetInstanceId),
])
}
function isUnmanagedInstanceError(error: unknown) {
return error instanceof Error && error.message.includes('is not managed')
}
try {
await ensureCriticalInstanceData(instanceId.value)
} catch (error) {
if (isUnmanagedInstanceError(error)) await router.replace('/')
else handleError(error)
}
onBeforeRouteUpdate(async (to, from) => {
const targetInstanceId = String(to.params.id ?? '')
const currentInstanceId = String(from.params.id ?? '')
if (!targetInstanceId || targetInstanceId === currentInstanceId) return
try {
await ensureCriticalInstanceData(targetInstanceId)
} catch (error) {
if (isUnmanagedInstanceError(error)) return { path: '/' }
handleError(error)
return false
}
})
useRootBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
id: () => `instance:${instanceId.value}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
tintBy: instance.value?.id ?? instanceId.value,
}),
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
to: () => `/instance/${encodeURIComponent(instanceId.value)}`,
})
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
@@ -250,16 +271,15 @@ const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const hiddenSharedInstanceUpdateKey = ref<string | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
useLoadingBarToken(subpagePending)
useLoadingBarToken(computed(() => instanceQuery.isPending.value && !instance.value))
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
@@ -270,9 +290,12 @@ const recentPlays = computed(
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
provideSharedInstanceState(sharedInstanceState)
const sharedInstanceState = createSharedInstanceContext(
instance,
offline,
notifySharedInstanceError,
)
provideSharedInstance(sharedInstanceState)
const {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
@@ -296,19 +319,6 @@ const showSharedInstanceUpdateAdmonition = computed(
sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value,
)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (
nextRoute.path.startsWith('/instance') &&
(!instance.value || nextRoute.params.id === instance.value.id)
) {
displayedInstanceRoute.value = nextRoute
}
},
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
@@ -323,124 +333,66 @@ function resetServerStatus() {
loadingServerPing.value = false
}
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
async function fetchInstance() {
const requestedInstanceId = route.params.id as string
const requestedRouteName = route.name
const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
if (!offline.value && nextInstance?.link && nextInstance.link.project_id) {
try {
nextLinkedProjectV3 = await get_project_v3(nextInstance.link.project_id, 'must_revalidate')
if (nextLinkedProjectV3?.minecraft_server != null) {
nextIsServerInstance = true
}
} catch (error) {
handleError(error as Error)
}
}
let nextPreloadedContent = await contentPreloadPromise
let nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
}
instance.value = nextInstance ?? undefined
if (nextInstance) {
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
}
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
resetServerStatus()
fetchDeferredData(nextInstance?.id)
if (nextInstance) {
queryClient.prefetchQuery({
queryKey: ['worlds', nextInstance.id],
queryFn: () => refreshWorlds(nextInstance.id),
staleTime: 30_000,
})
}
}
function fetchDeferredData(instanceId?: string) {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
if (
activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true
})
} else {
loadingServerPing.value = true
}
updatePlayState()
}
async function updatePlayState() {
if (!route.params.id) return
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
}
await fetchInstance()
const serverAddress = computed(() => linkedProjectV3.value?.minecraft_java_server?.address)
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/instance')) {
await fetchInstance()
[instanceId, serverAddress, isServerInstance],
([requestedInstanceId, address, serverInstance]) => {
resetServerStatus()
if (serverInstance && address) {
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, address)
.then((status) => {
if (instanceId.value !== requestedInstanceId || serverAddress.value !== address) return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${address}:`, error)
})
.finally(() => {
if (instanceId.value !== requestedInstanceId) return
loadingServerPing.value = true
})
} else {
loadingServerPing.value = true
}
},
{ immediate: true },
)
const basePath = computed(
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
async function refreshInstance() {
await Promise.all([instanceQuery.refetch(), sharedInstanceState.refreshAvailability()])
}
async function refreshPlayState() {
await processesQuery.refetch()
}
watch(
instanceQuery.error,
(error) => {
if (!error) return
if (error.message.includes('is not managed')) void router.replace('/')
else handleError(error)
},
{ immediate: true },
)
watch(
linkedProjectQuery.error,
(error) => {
if (error) handleError(error)
},
{ immediate: true },
)
const basePath = computed(() => `/instance/${encodeURIComponent(instanceId.value)}`)
/**
* Per-route layout mode.
@@ -451,25 +403,10 @@ const basePath = computed(
* Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`.
*/
const renderMode = computed<'scroll' | 'fixed'>(() =>
displayedInstanceRoute.value.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const { data: canCurrentUserUseSharedInstances } = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false,
)
const currentUserCanUseSharedInstances = sharedInstanceState.currentUserCanUseSharedInstances
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
@@ -534,12 +471,13 @@ const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const launchInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
const currentInstance = instance.value
loading.value = true
try {
await run(route.params.id as string)
playing.value = true
await run(currentInstance.id)
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [true])
} catch (err) {
handleSevereError(err, { instanceId: route.params.id as string })
handleSevereError(err, { instanceId: currentInstance.id })
}
loading.value = false
@@ -555,7 +493,7 @@ async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
await refreshInstance()
setSharedInstanceUnavailable(reason)
}
@@ -574,7 +512,7 @@ function reviewSharedInstanceUpdate(event: MouseEvent) {
currentInstance,
preview,
async () => {
await fetchInstance()
await refreshInstance()
},
event,
)
@@ -618,7 +556,7 @@ const startInstance = async (context: string) => {
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await refreshInstance()
await launchInstance(context)
})
return
@@ -628,7 +566,7 @@ const startInstance = async (context: string) => {
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await refreshInstance()
await launchInstance(context)
})
} else {
@@ -641,15 +579,16 @@ const startInstance = async (context: string) => {
}
const stopInstance = async (context: string) => {
const currentInstance = instance.value
if (!currentInstance) return
stopping.value = true
await kill(route.params.id as string).catch(handleError)
await kill(currentInstance.id).catch(handleError)
stopping.value = false
playing.value = false
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [])
if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
loader: currentInstance.loader,
game_version: currentInstance.game_version,
source: context,
})
}
@@ -660,26 +599,48 @@ const handlePlayServer = async () => {
try {
await playServerProject(instance.value.link.project_id)
} finally {
await updatePlayState()
await refreshPlayState()
loading.value = false
}
}
function openSettings(tab?: number) {
settingsModal.value?.show(tab)
}
async function browseContent(projectType?: string) {
const currentInstance = instance.value
if (!currentInstance || currentInstance.quarantined) return
await router.push({
path: `/browse/${projectType ?? (currentInstance.loader === 'vanilla' ? 'resourcepack' : 'mod')}`,
query: { i: currentInstance.id },
})
}
async function browseServers() {
if (!instance.value || instance.value.quarantined) return
await router.push({
path: '/browse/server',
query: { i: instance.value.id, from: 'worlds' },
})
}
const repairInstance = async () => {
if (instance.value.quarantined) return
const currentInstance = instance.value
if (!currentInstance || currentInstance.quarantined) return
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack')
currentInstance.install_stage !== 'pack_installed' &&
(currentInstance.link?.type === 'modrinth_modpack' ||
currentInstance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(instance.value.id, {
await install_pack_to_existing_instance(currentInstance.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,
project_id: currentInstance.link.project_id ?? currentInstance.link.server_project_id ?? '',
version_id: currentInstance.link.version_id ?? currentInstance.link.content_version_id ?? '',
title: currentInstance.name,
}).catch(handleError)
} else {
await install_existing_instance(instance.value.id, false).catch(handleError)
await install_existing_instance(currentInstance.id, false).catch(handleError)
}
}
@@ -786,15 +747,10 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
await stopInstance('InstancePageContextMenu')
break
case 'add_content':
await router.push({
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: route.params.id },
})
await browseContent(instance.value?.loader === 'vanilla' ? 'datapack' : 'mod')
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(route.params.id as string)}/options`,
})
openSettings()
break
case 'open_folder':
if (instance.value) await showInstanceInFolder(instance.value.id)
@@ -809,41 +765,79 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
}
}
const unlistenInstances = await instance_listener(
async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== route.params.id) return
let unlistenInstances: (() => void) | null = null
let unlistenProcesses: (() => void) | null = null
let instancePageAlive = true
provideInstancePage({
instanceId,
instance: instance as ComputedRef<GameInstance>,
linkedProject: linkedProjectV3,
isServerInstance,
offline,
playing,
loading,
stopping,
refreshInstance,
refreshPlayState,
play: startInstance,
stop: stopInstance,
playServer: handlePlayServer,
openSettings,
browseContent,
browseServers,
})
provideInstanceBackup(() => instance.value!)
function destroyInstanceConsole(targetInstanceId: string) {
void useInstanceConsole(targetInstanceId).destroy()
queryClient.removeQueries({ queryKey: instanceKeys.console(targetInstanceId), exact: true })
}
watch(instanceId, (currentInstanceId, previousInstanceId) => {
if (!previousInstanceId || previousInstanceId === currentInstanceId) return
destroyInstanceConsole(previousInstanceId)
})
onMounted(() => {
void instance_listener(async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'removed' || route.path === '/') {
if (route.path !== '/') {
await router.push({ path: '/' })
}
if (route.path !== '/') await router.push({ path: '/' })
return
}
instance.value = await get(route.params.id as string).catch((err) => {
if (String(err).includes('not managed')) {
router.push({ path: '/' })
return undefined
}
return handleError(err)
await queryClient.invalidateQueries({
queryKey: instanceKeys.detail(event.instance_id),
exact: true,
})
if (!instance.value?.link?.project_id) {
linkedProjectV3.value = undefined
isServerInstance.value = false
}
},
)
})
.then((unlisten) => {
if (instancePageAlive) unlistenInstances = unlisten
else unlisten()
})
.catch(handleError)
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
if (e.event === 'finished' && e.instance_id === route.params.id) {
playing.value = false
}
void process_listener((event: { event: string; instance_id: string }) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'finished') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [])
useInstanceConsole(event.instance_id).invalidate()
void queryClient.invalidateQueries({ queryKey: instanceKeys.logs(event.instance_id) })
} else if (event.event === 'launched') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [true])
}
})
.then((unlisten) => {
if (instancePageAlive) unlistenProcesses = unlisten
else unlisten()
})
.catch(handleError)
})
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const timePlayed = computed(() => {
return instance.value
? instance.value.recent_time_played + instance.value.submitted_time_played
@@ -851,210 +845,11 @@ const timePlayed = computed(() => {
})
onUnmounted(() => {
unlistenProcesses()
unlistenInstances()
const instanceId = displayedInstanceRoute.value.params.id
if (instanceId) {
const { destroy } = useInstanceConsole(instanceId)
destroy()
instancePageAlive = false
unlistenProcesses?.()
unlistenInstances?.()
if (instanceId.value) {
destroyInstanceConsole(instanceId.value)
}
})
</script>
<style scoped lang="scss">
.instance-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
Button {
width: 100%;
}
.button-group {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.side-cards {
position: fixed;
width: 300px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 3.25rem);
max-height: calc(100vh - 3.25rem);
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
.card {
min-height: unset;
margin-bottom: 0;
}
}
.instance-nav {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 1rem;
gap: 0.5rem;
background: var(--color-raised-bg);
height: 100%;
}
.name {
font-size: 1.25rem;
color: var(--color-contrast);
overflow: hidden;
text-overflow: ellipsis;
}
.metadata {
text-transform: capitalize;
}
.instance-container {
display: flex;
flex-direction: row;
overflow: auto;
gap: 1rem;
min-height: 100%;
padding: 1rem;
}
.instance-info {
display: flex;
flex-direction: column;
width: 100%;
}
.badge {
display: flex;
align-items: center;
font-weight: bold;
width: fit-content;
color: var(--color-orange);
}
.pages-list {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
.btn {
font-size: 100%;
font-weight: 400;
background: inherit;
transition: all ease-in-out 0.1s;
width: 100%;
color: var(--color-primary);
box-shadow: none;
&.router-link-exact-active {
box-shadow: var(--shadow-inset-lg);
background: var(--color-button-bg);
color: var(--color-contrast);
}
&:hover {
background-color: var(--color-button-bg);
color: var(--color-contrast);
box-shadow: var(--shadow-inset-lg);
text-decoration: none;
}
svg {
width: 1.3rem;
height: 1.3rem;
}
}
}
.instance-nav {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: left;
padding: 1rem;
gap: 0.5rem;
height: min-content;
width: 100%;
}
.instance-button {
width: fit-content;
}
.actions {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 0.5rem;
}
.content {
margin: 0 1rem 0.5rem 20rem;
width: calc(100% - 20rem);
display: flex;
flex-direction: column;
overflow: auto;
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--gap-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--gap-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--gap-md);
margin-top: var(--gap-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
}
</style>
@@ -11,51 +11,20 @@ import {
injectNotificationManager,
provideConsoleManager,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { log_listener, process_listener } from '@/helpers/events.js'
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
import { injectInstancePage } from '../instance-context'
import { instanceKeys } from '../query-options'
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const route = useRoute()
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
installed: {
type: Boolean,
default() {
return false
},
},
})
const instanceId = computed(() => route.params.id)
const instancePage = injectInstancePage()
const instanceId = instancePage.instanceId
const {
liveConsole,
historicalConsole,
@@ -66,7 +35,17 @@ const {
clearLive,
} = useInstanceConsole(instanceId.value)
await hydrate()
const consoleHydrationQuery = useQuery({
queryKey: computed(() => instanceKeys.console(instanceId.value)),
queryFn: async () => {
await hydrate()
return true
},
staleTime: 0,
refetchOnMount: 'always',
})
await consoleHydrationQuery.suspense()
function buildLogList(rawLogs) {
return [
@@ -88,18 +67,29 @@ function buildLogList(rawLogs) {
}
const logs = ref(buildLogList([]))
void getHistoricalLogs()
.then((allLogs) => {
logs.value = buildLogList(allLogs)
})
.catch(handleError)
const historicalLogsQuery = useQuery({
queryKey: computed(() => instanceKeys.logs(instanceId.value)),
queryFn: getHistoricalLogs,
staleTime: 0,
})
watch(
historicalLogsQuery.data,
(allLogs) => {
if (allLogs) logs.value = buildLogList(allLogs)
},
{ immediate: true },
)
watch(historicalLogsQuery.error, (error) => {
if (error) handleError(error)
})
const selectedLogIndex = ref(0)
const isLive = computed(() => selectedLogIndex.value === 0)
const filteredLogs = computed(() =>
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
instancePage.playing.value
? logs.value.filter((l) => l.live || l.name !== 'latest.log')
: logs.value,
)
const logSources = computed(() =>
@@ -140,16 +130,16 @@ const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
const deleteDisabled = computed(() => {
const log = selectedLog.value
if (!log || log.live) return true
return log.filename === 'latest.log' && props.playing
return log.filename === 'latest.log' && instancePage.playing.value
})
async function deleteSelectedLog() {
const log = selectedLog.value
if (!log || log.live) return
await delete_logs_by_filename(props.instance.id, log.log_type, log.filename)
await delete_logs_by_filename(instanceId.value, log.log_type, log.filename)
invalidate()
const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs)
const { data } = await historicalLogsQuery.refetch()
if (data) logs.value = buildLogList(data)
selectedLogIndex.value = 0
}
@@ -166,7 +156,7 @@ provideConsoleManager({
onDelete: deleteSelectedLog,
deleteDisabled,
deleteDisabledTooltip: 'Cannot delete latest.log while the instance is running',
shareDisabled: computed(() => props.offline),
shareDisabled: instancePage.offline,
emptyStateType: 'instance',
crashAnalysis,
onDismissCrash: () => {
@@ -186,7 +176,7 @@ watch(selectedLogIndex, async (newIndex) => {
return
}
const output = await get_output_by_filename(props.instance.id, log.log_type, log.filename).catch(
const output = await get_output_by_filename(instanceId.value, log.log_type, log.filename).catch(
handleError,
)
if (output) {
@@ -197,7 +187,7 @@ watch(selectedLogIndex, async (newIndex) => {
selectedLogIndex.value = 0
if (!props.playing) {
if (!instancePage.playing.value) {
void analyseForCrash()
}
@@ -216,12 +206,13 @@ const unlistenProcesses = await process_listener(async (e) => {
if (e.event === 'launched') {
liveConsole.clear()
invalidate()
void historicalLogsQuery.refetch()
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
invalidate()
const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs)
const { data } = await historicalLogsQuery.refetch()
if (data) logs.value = buildLogList(data)
void analyseForCrash()
}
})
@@ -0,0 +1,79 @@
import { queryOptions } from '@tanstack/vue-query'
import { get_project_v3 } from '@/helpers/cache.js'
import { get as getInstance } from '@/helpers/instance'
import { loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { refreshWorlds } from '@/helpers/worlds'
export const instanceKeys = {
all: ['instances'] as const,
detail: (instanceId: string) => [...instanceKeys.all, 'summary', instanceId] as const,
processes: (instanceId: string) => [...instanceKeys.all, 'processes', instanceId] as const,
content: (instanceId: string) => [...instanceKeys.all, 'content', instanceId] as const,
rootPath: (instanceId: string) => [...instanceKeys.detail(instanceId), 'root-path'] as const,
files: (instanceId: string, path: string) =>
[...instanceKeys.detail(instanceId), 'files', path] as const,
console: (instanceId: string) => [...instanceKeys.detail(instanceId), 'console'] as const,
logs: (instanceId: string) => [...instanceKeys.detail(instanceId), 'logs'] as const,
installedProjectIds: (instanceId: string, source: 'content' | 'worlds') =>
[...instanceKeys.detail(instanceId), 'installed-project-ids', source] as const,
linkedContent: (instanceId: string) => ['linkedModpackContent', instanceId] as const,
worlds: (instanceId: string) => ['worlds', instanceId] as const,
linkedProject: (projectId: string) => ['project', 'v3', projectId] as const,
sharedEligibility: (userId: string | null | undefined) =>
['shared-instance-eligibility', userId] as const,
sharedUpdatePreview: (instanceId: string, userId: string | null | undefined) =>
[...instanceKeys.detail(instanceId), 'shared-update-preview', userId] as const,
sharedMembers: (instanceId: string) => ['sharedInstanceUsers', instanceId] as const,
}
export function instanceDetailQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.detail(instanceId),
queryFn: async () => {
const instance = await getInstance(instanceId)
if (!instance) throw new Error(`Instance ${instanceId} is not managed`)
return instance
},
staleTime: 30_000,
})
}
export function instanceProcessesQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.processes(instanceId),
queryFn: async () => {
const processes = await get_by_instance_id(instanceId)
return Array.isArray(processes) ? processes : []
},
staleTime: 0,
})
}
export function instanceLinkedProjectQueryOptions(projectId: string) {
return queryOptions({
queryKey: instanceKeys.linkedProject(projectId),
queryFn: () => get_project_v3(projectId, 'must_revalidate'),
staleTime: 30_000,
})
}
export function instanceContentQueryOptions(
instanceId: string,
onError?: (error: Error) => unknown,
) {
return queryOptions({
queryKey: instanceKeys.content(instanceId),
queryFn: () => loadInstanceContentData(instanceId, undefined, onError),
staleTime: 30_000,
})
}
export function instanceWorldsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.worlds(instanceId),
queryFn: () => refreshWorlds(instanceId),
staleTime: 0,
})
}
@@ -55,20 +55,7 @@
<div v-else-if="membersTableLoading" class="h-64" aria-hidden="true" />
<SharedInstanceMembersTable
v-else-if="showMembersTable"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-disabled="!hasRemainingUserSlots"
:invite-pending="inviteLink.pending.value"
:push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
"
:push-update-pending="publishState !== 'idle'"
@invite="showInvitePlayers"
@remove="showRemoveMemberModal"
@push-update="reviewUpdate"
/>
<SharedInstanceMembersTable v-else-if="showMembersTable" />
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceUnavailable"
@@ -156,8 +143,8 @@ import {
type InvitePlayersUser,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, toRef, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
@@ -166,16 +153,16 @@ import {
isSharedInstancesApiError,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { can_current_user_use_shared_instances, edit } from '@/helpers/instance'
import { edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectSharedInstanceState } from '../use-shared-instance-state'
import { injectInstancePage } from '../instance-context'
import { injectSharedInstance } from '../shared-instance-context'
import { provideSharedInstanceManagement } from './shared-instance-management-context'
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
@@ -184,10 +171,7 @@ import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const instancePage = injectInstancePage()
const auth = injectAuth()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
@@ -196,8 +180,9 @@ const {
notifySharedInstanceError,
notifySharedInstanceUnavailable,
} = useSharedInstanceErrors()
const sharedInstanceState = injectSharedInstanceState()
const instance = toRef(props, 'instance')
const sharedInstanceState = injectSharedInstance()
const instance = computed(() => instancePage.instance.value!)
const offline = instancePage.offline
const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
@@ -224,16 +209,7 @@ function notifyOperationError(error: unknown) {
}
}
const eligibilityQuery = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]),
queryFn: can_current_user_use_shared_instances,
enabled: () => isSignedIn.value && !!currentUserId.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const eligibilityQuery = sharedInstanceState.eligibilityQuery
const members = useSharedInstanceMembers({
instance,
@@ -257,7 +233,7 @@ const {
actionsLocked,
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
computed(() => instance.value.id),
remainingUserSlots,
notifyOperationError,
)
@@ -286,7 +262,7 @@ const unableToConnect = computed(
const membersTableLoading = computed(
() =>
members.rows.value.length === 0 &&
!!props.instance.shared_instance &&
!!instance.value.shared_instance &&
(members.query.data.value === undefined || members.query.isFetching.value) &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value,
@@ -294,7 +270,7 @@ const membersTableLoading = computed(
const showMembersTable = computed(
() =>
members.rows.value.length > 0 ||
(!!props.instance.shared_instance &&
(!!instance.value.shared_instance &&
members.query.data.value !== undefined &&
!members.query.isFetching.value &&
!sharedInstanceUnavailable.value &&
@@ -302,13 +278,13 @@ const showMembersTable = computed(
)
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
!props.instance.shared_instance &&
instance.value.link?.type === 'imported_modpack' &&
!instance.value.shared_instance &&
!importedModpackUnlinked.value,
)
const importedModpackBackupTip = computed(() =>
props.instance.link?.type === 'imported_modpack'
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
instance.value.link?.type === 'imported_modpack'
? (instance.value.link.name ?? instance.value.link.filename ?? undefined)
: undefined,
)
@@ -395,9 +371,9 @@ async function showInvitePlayers(event?: MouseEvent) {
}
async function unlinkImportedModpack() {
try {
await edit(props.instance.id, { link: null as unknown as undefined })
await edit(instance.value.id, { link: null as unknown as undefined })
importedModpackUnlinked.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
} catch (error) {
notifyOperationError(error)
@@ -419,7 +395,7 @@ function userProfileLink(username: string) {
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, {
await auth.requestSignIn(`/instance/${encodeURIComponent(instance.value.id)}/share`, flow, {
showModal: false,
})
return !!auth.session_token.value
@@ -428,6 +404,23 @@ function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
provideSharedInstanceManagement({
rows: members.rows,
actionsLocked: sharedInstanceActionsLocked,
inviteDisabled: computed(() => !hasRemainingUserSlots.value),
invitePending: inviteLink.pending,
pushUpdateDisabled: computed(
() =>
instance.value.install_stage !== 'installed' ||
publishState.value !== 'idle' ||
offline.value,
),
pushUpdatePending: computed(() => publishState.value !== 'idle'),
invite: (event) => void showInvitePlayers(event),
remove: showRemoveMemberModal,
pushUpdate: reviewUpdate,
})
watch(
[eligibilityQuery.error, members.query.error],
(errors) => {
@@ -443,7 +436,7 @@ watch([eligibilityQuery.data, members.query.data], ([eligibility, memberRows]) =
}
})
watch(
() => props.instance.id,
() => instance.value.id,
() => {
importedModpackUnlinked.value = false
},
@@ -455,6 +448,4 @@ watch(
},
{ immediate: true, flush: 'post' },
)
provideInstanceBackup(() => props.instance)
</script>
@@ -0,0 +1,19 @@
import { createContext } from '@modrinth/ui'
import type { ComputedRef, Ref } from 'vue'
import type { ShareRow } from './shared-instance-share-types'
export interface SharedInstanceManagementContext {
readonly rows: ComputedRef<ShareRow[]>
readonly actionsLocked: Ref<boolean>
readonly inviteDisabled: ComputedRef<boolean>
readonly invitePending: Ref<boolean>
readonly pushUpdateDisabled: ComputedRef<boolean>
readonly pushUpdatePending: ComputedRef<boolean>
invite: (event: MouseEvent) => void
remove: (row: ShareRow) => void
pushUpdate: (event: MouseEvent) => void
}
export const [injectSharedInstanceManagement, provideSharedInstanceManagement] =
createContext<SharedInstanceManagementContext>('InstanceSharePage')
@@ -15,7 +15,7 @@
<button
class="flex !h-10 shrink-0 items-center gap-2 !border"
:disabled="pushUpdateDisabled"
@click="emit('push-update', $event)"
@click="management.pushUpdate($event)"
>
<SpinnerIcon v-if="pushUpdatePending" class="animate-spin" aria-hidden="true" />
<UploadIcon v-else aria-hidden="true" />
@@ -26,7 +26,7 @@
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending || inviteDisabled"
@click="emit('invite', $event)"
@click="management.invite($event)"
>
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
<UserPlusIcon v-else aria-hidden="true" />
@@ -125,7 +125,7 @@
v-tooltip="'Revoke access'"
:aria-label="`Revoke access for ${row.username}`"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="emit('remove', row)"
@click="management.remove(row)"
>
<XIcon aria-hidden="true" /></button
></ButtonStyled>
@@ -161,6 +161,7 @@ import {
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { injectSharedInstanceManagement } from './shared-instance-management-context'
import {
type MethodFilter,
methodLabels,
@@ -169,19 +170,15 @@ import {
type ShareTableColumn,
} from './shared-instance-share-types'
const props = defineProps<{
rows: ShareRow[]
actionsLocked?: boolean
inviteDisabled?: boolean
invitePending?: boolean
pushUpdateDisabled?: boolean
pushUpdatePending?: boolean
}>()
const emit = defineEmits<{
invite: [event: MouseEvent]
remove: [row: ShareRow]
'push-update': [event: MouseEvent]
}>()
const management = injectSharedInstanceManagement()
const {
rows,
actionsLocked,
inviteDisabled,
invitePending,
pushUpdateDisabled,
pushUpdatePending,
} = management
const search = ref('')
const methodFilter = ref<MethodFilter>('all')
const sortColumn = ref<string | undefined>('joined')
@@ -194,7 +191,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
{ id: 'direct', label: methodLabels.direct },
{ id: 'link', label: methodLabels.link },
]
const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1)
const hasMultipleMethods = computed(() => new Set(rows.value.map((row) => row.method)).size > 1)
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
const result: TableColumn<ShareTableColumn>[] = [
{
@@ -230,7 +227,7 @@ const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
cellClass: 'whitespace-nowrap !px-2',
},
]
if (!props.actionsLocked)
if (!actionsLocked.value)
result.push({
key: 'actions',
label: 'Actions',
@@ -243,7 +240,7 @@ const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
})
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase()
return props.rows.filter((row) => {
return rows.value.filter((row) => {
if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
if (!query) return true
return [
@@ -12,13 +12,14 @@ import {
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { instanceKeys } from '../query-options'
import {
normalizeInviteKey,
SHARED_INSTANCE_USER_LIMIT,
type ShareRow,
} from './shared-instance-share-types'
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
type MembersQueryKey = ReturnType<typeof instanceKeys.sharedMembers>
type OptimisticChange = {
queryKey: MembersQueryKey
@@ -48,7 +49,7 @@ export function useSharedInstanceMembers(options: {
onError: (error: unknown) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
const queryKey = computed(() => instanceKeys.sharedMembers(options.instance.value.id))
const invitingUserIds = new Set<string>()
const removingUserIds = new Set<string>()
const exclusiveMutationPending = ref(false)
@@ -0,0 +1,181 @@
import { createContext, injectAuth } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import { can_current_user_use_shared_instances } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { instanceKeys } from './query-options'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function createSharedInstanceContext(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const queryClient = useQueryClient()
const forcedUnavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
const eligibilityQuery = useQuery({
queryKey: computed(() => instanceKeys.sharedEligibility(auth.user.value?.id)),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || eligibilityQuery.data.value !== false,
)
const updatePreviewQuery = useQuery({
queryKey: computed(() =>
instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id),
),
queryFn: () => install_get_shared_instance_update_preview(instance.value!.id),
enabled: computed(
() =>
!!instance.value?.id &&
!!instance.value.shared_instance &&
!actionsLocked.value &&
!offline.value &&
(auth.isReady?.value ?? true) &&
!!auth.session_token.value &&
!!auth.user.value?.id,
),
retry: false,
staleTime: 30_000,
refetchOnWindowFocus: false,
})
watch(updatePreviewQuery.data, (preview) => {
if (preview !== undefined) forcedUnavailableReason.value = null
})
watch(updatePreviewQuery.error, (error) => {
if (!error) return
if (isSharedInstanceUnavailableError(error)) {
forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error)
} else {
notifyError(error)
}
})
const unavailableReason = computed(() => forcedUnavailableReason.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const updatePreview = computed(() =>
unavailableReason.value ? null : (updatePreviewQuery.data.value ?? null),
)
watch(
() => instance.value?.id,
() => {
forcedUnavailableReason.value = null
},
)
async function refreshAvailability() {
forcedUnavailableReason.value = null
if (!instance.value?.id) return
await queryClient.invalidateQueries({
queryKey: instanceKeys.sharedUpdatePreview(instance.value.id, auth.user.value?.id),
})
}
async function refreshUpdatePreview() {
forcedUnavailableReason.value = null
if (!instance.value?.id || !auth.user.value?.id) return null
const result = await updatePreviewQuery.refetch({ throwOnError: true })
return result.data ?? null
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
forcedUnavailableReason.value = reason
}
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
eligibilityQuery,
currentUserCanUseSharedInstances,
refreshAvailability,
refreshUpdatePreview,
setUnavailable,
}
}
export type SharedInstanceContext = ReturnType<typeof createSharedInstanceContext>
export const [injectSharedInstance, provideSharedInstance] = createContext<SharedInstanceContext>(
'InstancePage',
'sharedInstance',
)
@@ -1,228 +0,0 @@
import { injectAuth } from '@modrinth/ui'
import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function useSharedInstanceState(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const updatePreview =
ref<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>(null)
const updatePreviewLoaded = ref(false)
const unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const availabilityCheckKey = ref<string | null>(null)
const availabilityRefresh = ref(0)
let availabilityRequestId = 0
let availabilityRequest: {
key: string
promise: Promise<{
preview: Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>
error: unknown | null
}>
} | null = null
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
function reset() {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = null
}
function refreshAvailability() {
availabilityCheckKey.value = null
updatePreviewLoaded.value = false
availabilityRefresh.value++
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = reason
}
async function checkAvailability(instanceId: string, key: string, throwError = false) {
const requestId = ++availabilityRequestId
let request = availabilityRequest
if (!request || request.key !== key) {
const promise = install_get_shared_instance_update_preview(instanceId).then(
(preview) => ({ preview, error: null }),
(error: unknown) => ({ preview: null, error }),
)
request = { key, promise }
availabilityRequest = request
void promise.finally(() => {
if (availabilityRequest?.promise === promise) availabilityRequest = null
})
}
const result = await request.promise
if (!isCurrentRequest(requestId, instanceId, key)) return null
if (result.error !== null) {
updatePreviewLoaded.value = false
if (isSharedInstanceUnavailableError(result.error)) {
updatePreview.value = null
unavailableReason.value = getSharedInstanceUnavailableReason(result.error)
} else if (!throwError) {
notifyError(result.error)
}
if (throwError) throw result.error
return null
}
updatePreview.value = result.preview
updatePreviewLoaded.value = true
unavailableReason.value = null
return result.preview
}
async function refreshUpdatePreview() {
const instanceId = instance.value?.id
const userId = auth.user.value?.id
if (!instanceId || !userId) return null
const key = `${instanceId}:${userId}`
availabilityCheckKey.value = key
return await checkAvailability(instanceId, key, true)
}
function isCurrentRequest(requestId: number, instanceId: string, key: string) {
return (
requestId === availabilityRequestId &&
instance.value?.id === instanceId &&
availabilityCheckKey.value === key
)
}
watch(
() => ({
refresh: availabilityRefresh.value,
instanceId: instance.value?.id,
role: instance.value?.shared_instance?.role,
locked: actionsLocked.value,
offline: offline.value,
signedIn: !!auth.session_token.value,
userId: auth.user.value?.id ?? null,
authReady: auth.isReady?.value ?? true,
}),
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
if (instanceId && role) unavailableReason.value = null
return
}
const key = `${instanceId}:${userId}`
if (availabilityCheckKey.value === key) return
availabilityCheckKey.value = key
await checkAvailability(instanceId, key)
},
{ immediate: true },
)
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
reset,
refreshAvailability,
refreshUpdatePreview,
setUnavailable,
}
}
export type SharedInstanceState = ReturnType<typeof useSharedInstanceState>
const sharedInstanceStateKey: InjectionKey<SharedInstanceState> = Symbol('shared-instance-state')
export function provideSharedInstanceState(state: SharedInstanceState) {
provide(sharedInstanceStateKey, state)
}
export function injectSharedInstanceState() {
const state = inject(sharedInstanceStateKey)
if (!state) throw new Error('Shared instance state has not been provided.')
return state
}
@@ -42,12 +42,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<button class="!h-10 flex items-center gap-2" @click="instancePage.browseServers">
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
@@ -105,7 +100,7 @@
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
:shortcut-instance-id="instance.id"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@stop="() => instancePage.stop('InstanceWorlds')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
@@ -134,12 +129,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<button class="!h-10 flex items-center gap-2" @click="instancePage.browseServers">
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
@@ -166,9 +156,8 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { platform } from '@tauri-apps/plugin-os'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import type ContextMenu from '@/components/ui/ContextMenu.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'
@@ -178,7 +167,6 @@ import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
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,
@@ -194,7 +182,6 @@ import {
refreshServerData,
refreshServers,
refreshWorld,
refreshWorlds,
remove_server_from_instance,
resolveManagedServerWorld,
type ServerData,
@@ -209,6 +196,9 @@ import {
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { injectInstancePage } from '../instance-context'
import { instanceKeys, instanceWorldsQueryOptions } from '../query-options'
const messages = defineMessages({
searchWorldsPlaceholder: {
id: 'app.instance.worlds.search-worlds-placeholder',
@@ -256,7 +246,7 @@ const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const route = useRoute()
const router = useRouter()
const instancePage = injectInstancePage()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
@@ -265,25 +255,12 @@ const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
const worldToRemove = ref<World | null>(null)
const emit = defineEmits<{
(event: 'play', world: World): void
(event: 'stop'): void
}>()
const instance = computed(() => instancePage.instance.value!)
const playing = instancePage.playing
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu> | null
offline: boolean
playing: boolean
installed: boolean
}>()
const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
if (props.instance.quarantined) return
emit('play', world)
function play() {
if (instance.value.quarantined) return
void instancePage.refreshPlayState()
}
const selectedFilters = ref<string[]>([])
@@ -319,11 +296,12 @@ const hadNoWorlds = ref(true)
const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worldsQuery = useQuery({
queryKey: computed(() => ['worlds', instance.value.id]),
queryFn: () => refreshWorlds(instance.value.id),
staleTime: 30_000,
})
const worldsQuery = useQuery(
computed(() => ({
...instanceWorldsQueryOptions(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
})),
)
const worldsReadyPending = useReadyState(worldsQuery)
@@ -497,7 +475,7 @@ async function refreshAllWorlds() {
}
}
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.value.id) })
await refreshServers(
worlds.value,
serverData.value,
@@ -592,7 +570,7 @@ async function joinWorld(world: World) {
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
}
play(world)
play()
startingInstance.value = false
}
+6 -7
View File
@@ -141,7 +141,6 @@ export default new createRouter({
path: '/instance/:id',
name: 'Instance',
component: Instance.Index,
props: true,
children: [
{
path: 'worlds',
@@ -155,22 +154,22 @@ export default new createRouter({
},
{
path: '',
name: 'Mods',
component: Instance.Mods,
name: 'InstanceContent',
component: Instance.Content,
},
{
path: 'projects/:type',
name: 'ModsFilter',
component: Instance.Mods,
name: 'InstanceContentFilter',
component: Instance.Content,
},
{
path: 'files',
name: 'Files',
name: 'InstanceFiles',
component: Instance.Files,
},
{
path: 'logs',
name: 'Logs',
name: 'InstanceLogs',
component: Instance.Logs,
meta: {
renderMode: 'fixed',