Files
Starlight_Lancher/apps/app-frontend/src/pages/instance/Index.vue
T

1158 lines
33 KiB
Vue

<template>
<div
v-if="instance"
:class="{
'flex h-full min-h-0 flex-col': isFixedRender,
'instance-fixed-render': isFixedRender,
}"
>
<div
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender, hidden: isStudioMode }]"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal
:key="instance.id"
ref="settingsModal"
:instance="instance"
:offline="offline"
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" />
<ContentPageHeader>
<template #icon>
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
:alt="instance.name"
size="64px"
/>
</template>
<template #title>
{{ instance.name }}
</template>
<template #stats>
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
<div class="flex items-center gap-1.5 font-medium">
<BoxIcon class="h-4 w-4" />
{{ instance.game_version }}
</div>
<Badge
v-if="postUpgradeNotice"
color="green"
:type="
formatMessage(messages.upgradedTo, {
version: postUpgradeNotice.targetGameVersion,
})
"
/>
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-1.5 font-medium capitalize">
<component :is="loaderIcon" v-if="loaderIcon" class="h-4 w-4" />
{{ instance.loader }} {{ instance.loader_version ? instance.loader_version : '' }}
</div>
<template v-if="showInstancePlayTime">
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-2 font-medium">
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
</div>
</template>
</template>
<template v-else>
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div
v-if="minecraftServer?.region || ping"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
{{ formatMessage(messages.linkedTo) }}
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.id"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
<div
v-if="instance?.symlink_target && symlinkWarning.isHidden.value"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<SymlinkInstanceWarning
v-if="instance?.symlink_target && symlinkWarning.isHidden.value"
:symlink-target="instance.symlink_target"
badge-only
/>
</div>
</template>
<template #actions>
<div data-onboarding-id="instance-actions" class="flex gap-2">
<ButtonStyled
v-if="
[
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
].includes(instance.install_stage)
"
color="brand"
size="large"
>
<button disabled>{{ formatMessage(commonMessages.installingLabel) }}</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button
v-tooltip="offline ? formatMessage(messages.offlineInstalledOnly) : undefined"
:disabled="offline"
@click="repairInstance()"
>
<DownloadIcon />
{{ formatMessage(commonMessages.repairButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="loading" color="brand" size="large">
<button disabled>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="!isServerInstance" color="brand" size="large">
<button @click="startInstance">
<PlayIcon />
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button>
</ButtonStyled>
<div v-else class="joined-buttons">
<ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()">
<PlayIcon />
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand" size="large">
<OverflowMenu
:options="[
{
id: 'join_server',
action: () => handlePlayServer(),
},
{
id: 'launch_instance',
action: startInstance,
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<template #join_server>
<PlayIcon />
{{ formatMessage(messages.joinServer) }}
</template>
<template #launch_instance>
<PlayIcon />
{{ formatMessage(messages.launchInstance) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-if="playing" color="red" size="large">
<button :disabled="stopping || loading" @click="stopInstance">
<StopCircleIcon />
{{ formatMessage(stopping ? messages.stopping : messages.stopAll) }}
</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button
v-tooltip="formatMessage(messages.instanceSettings)"
@click="settingsModal?.show()"
>
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular size="large">
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => {
if (instance) showInstanceInFolder(instance.id)
},
},
{
id: 'export-mrpack',
action: () => exportModal?.show(),
},
{
id: 'create-shortcut',
action: () => createShortcut(),
},
...(canUpgradeInstance
? [{ id: 'upgrade-instance', action: () => openUpgrade() }]
: []),
]"
>
<MoreVerticalIcon />
<template #share-instance>
<UserPlusIcon /> {{ formatMessage(messages.shareInstance) }}
</template>
<template #host-a-server>
<ServerIcon /> {{ formatMessage(messages.createServer) }}
</template>
<template #open-folder>
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #export-mrpack>
<PackageIcon /> {{ formatMessage(messages.exportModpack) }}
</template>
<template #create-shortcut>
<ExternalIcon /> {{ formatMessage(messages.createShortcut) }}
</template>
<template #upgrade-instance>
<UpdatedIcon /> {{ formatMessage(messages.upgradeInstance) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</ContentPageHeader>
</div>
<div
data-onboarding-id="instance-tabs"
:class="['px-6', { 'shrink-0': isFixedRender, hidden: isStudioMode }]"
>
<SymlinkInstanceWarning
v-if="instance?.symlink_target && !symlinkWarning.isHidden.value"
:symlink-target="instance.symlink_target"
class="mb-3"
dismissible
/>
<NavTabs v-if="!hideInstanceTabs" :links="tabs" />
</div>
<div :class="['p-6 pt-4', { 'flex min-h-0 flex-1 flex-col overflow-hidden': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
<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)"
@play="updatePlayState"
@stop="stopInstance"
></component>
</Suspense>
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EditIcon /> {{ formatMessage(commonMessages.editButton) }} </template>
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
<template #open_folder>
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy_link>
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
</template>
<template #open_link>
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
</template>
<template #copy_names><EditIcon />{{ formatMessage(messages.copyNames) }}</template>
<template #copy_slugs><HashIcon />{{ formatMessage(messages.copySlugs) }}</template>
<template #copy_links><GlobeIcon />{{ formatMessage(messages.copyLinks) }}</template>
<template #toggle><EditIcon />{{ formatMessage(messages.toggleSelected) }}</template>
<template #disable><XIcon />{{ formatMessage(messages.disableSelected) }}</template>
<template #enable><CheckCircleIcon />{{ formatMessage(messages.enableSelected) }}</template>
<template #hide_show><EyeIcon />{{ formatMessage(messages.hideShowUnselected) }}</template>
<template #update_all
><UpdatedIcon />{{
selected.length > 0
? formatMessage(messages.updateSelected)
: formatMessage(messages.updateAll)
}}</template
>
<template #filter_update
><UpdatedIcon />{{ formatMessage(messages.selectUpdatable) }}</template
>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BoxesIcon,
BoxIcon,
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
DropdownIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
getLoaderIcon,
GlobeIcon,
HashIcon,
ImageIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
SpinnerIcon,
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Badge,
ButtonStyled,
commonMessages,
ContentPageHeader,
defineMessages,
injectNotificationManager,
NavTabs,
OverflowMenu,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { useInstanceMode } from '@/composables/useInstanceMode'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import {
install_existing_instance,
install_job_list,
install_pack_to_existing_instance,
} from '@/helpers/install'
import {
allow_symlink_target,
gcReportFellBack,
get,
get_full_path,
kill,
run,
} from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { isActiveUpgradeJobForInstance, isUnmanagedUpgradeEligible } from './upgrade/entry'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const queryClient = useQueryClient()
const route = useRoute()
const props = defineProps<{
id: string
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
upgradedTo: {
id: 'app.instance.post-upgrade-status',
defaultMessage: 'Upgraded to {version}',
},
linkedTo: { id: 'app.instance.linked-to', defaultMessage: 'Linked to' },
stopping: { id: 'app.instance.stopping', defaultMessage: 'Stopping...' },
joinServer: { id: 'app.instance.join-server', defaultMessage: 'Join server' },
launchInstance: { id: 'app.instance.launch-instance', defaultMessage: 'Launch instance' },
starting: { id: 'app.instance.starting', defaultMessage: 'Starting... {seconds, number}s' },
instanceSettings: { id: 'app.instance.settings', defaultMessage: 'Instance settings' },
shareInstance: { id: 'app.instance.share', defaultMessage: 'Share instance' },
createServer: { id: 'app.instance.create-server', defaultMessage: 'Create a server' },
exportModpack: { id: 'app.instance.export-modpack', defaultMessage: 'Export modpack' },
createShortcut: { id: 'app.instance.create-shortcut', defaultMessage: 'Create shortcut' },
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
copyPath: { id: 'app.instances.copy-path', defaultMessage: 'Copy path' },
copyNames: { id: 'app.instance.copy-names', defaultMessage: 'Copy names' },
copySlugs: { id: 'app.instance.copy-slugs', defaultMessage: 'Copy slugs' },
copyLinks: { id: 'app.instance.copy-links', defaultMessage: 'Copy links' },
toggleSelected: { id: 'app.instance.toggle-selected', defaultMessage: 'Toggle selected' },
disableSelected: { id: 'app.instance.disable-selected', defaultMessage: 'Disable selected' },
enableSelected: { id: 'app.instance.enable-selected', defaultMessage: 'Enable selected' },
hideShowUnselected: {
id: 'app.instance.hide-show-unselected',
defaultMessage: 'Show/hide unselected',
},
updateSelected: { id: 'app.instance.update-selected', defaultMessage: 'Update selected' },
updateAll: { id: 'app.instance.update-all', defaultMessage: 'Update all' },
selectUpdatable: { id: 'app.instance.select-updatable', defaultMessage: 'Select updatable' },
contentTab: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
filesTab: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
worldsTab: { id: 'app.instance.tabs.worlds', defaultMessage: 'Worlds' },
screenshotsTab: { id: 'app.instance.tabs.screenshots', defaultMessage: 'Screenshots' },
logsTab: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
offlineInstalledOnly: {
id: 'app.instance.offline-installed-only',
defaultMessage: 'Offline mode can only launch fully downloaded instances.',
},
gcFallbackNotice: {
id: 'app.gc-notice.fallback',
defaultMessage: 'Java did not accept {preferred}; using {chosen}.',
},
})
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const { offline } = useNetworkStatus()
const instance = ref<GameInstance>()
const instanceId = computed(() => instance.value?.id)
const instanceModeQuery = useInstanceMode(() => instance.value?.id ?? props.id)
const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ?? props.id)
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
const symlinkWarning = useSymlinkWarningDismiss(instanceId)
const playing = ref(false)
const serverLaunchingId = ref<string>()
const loading = computed(
() => isInstanceLaunching(props.id) || serverLaunchingId.value === props.id,
)
const launchElapsedSeconds = ref(0)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
let launchElapsedTimer: ReturnType<typeof setInterval> | undefined
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const canUpgradeInstance = computed(() =>
instance.value && instanceModeQuery.data.value === 'local'
? isUnmanagedUpgradeEligible(instance.value)
: false,
)
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
const recentPlays = computed(
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const loaderIcon = computed(() =>
instance.value?.loader ? getLoaderIcon(instance.value.loader) : undefined,
)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance') && nextRoute.params.id === props.id) {
displayedInstanceRoute.value = nextRoute
}
},
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
liveServerStatusOnline.value = true
loadingServerPing.value = true
}
function resetServerStatus() {
ping.value = undefined
playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.value = false
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
resetServerStatus()
const nextInstance = await get(props.id).catch(handleError)
instance.value = nextInstance ?? undefined
activeInstanceId.value = nextInstance?.id
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
if (nextInstance?.symlink_target) {
await allow_symlink_target(nextInstance.symlink_target).catch(() => {})
}
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)
}
}
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
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() {
const id = props.id
const runningProcesses = await get_by_instance_id(id).catch(handleError)
if (props.id === id && Array.isArray(runningProcesses))
playing.value = runningProcesses.length > 0
}
await fetchInstance()
watch(
() => props.id,
async () => {
if (route.path.startsWith('/instance')) {
playing.value = false
await fetchInstance()
}
},
)
const basePath = computed(
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
)
/**
* Per-route layout mode.
* - `'scroll'` (default): the whole instance page scrolls inside `.app-viewport`. This lets
* `position: sticky` children (and the viewport-rooted `IntersectionObserver` used by
* `useStickyObserver`) work correctly.
* - `'fixed'`: the header + tabs are pinned and only the tab body scrolls in its own container.
* 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',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const hideInstanceTabs = computed(() =>
displayedInstanceRoute.value.matched.some((record) => record.meta.hideInstanceTabs === true),
)
const isStudioMode = computed(() => displayedInstanceRoute.value.name === 'FileStudio')
const tabs = computed(() => [
{
label: formatMessage(messages.contentTab),
href: `${basePath.value}`,
icon: BoxesIcon,
onboardingId: 'instance-tab-content',
},
{
label: formatMessage(messages.filesTab),
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
onboardingId: 'instance-tab-files',
},
{
label: formatMessage(messages.screenshotsTab),
href: `${basePath.value}/screenshots`,
icon: ImageIcon,
},
{
label: formatMessage(messages.worldsTab),
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
onboardingId: 'instance-tab-worlds',
},
{
label: formatMessage(messages.logsTab),
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
onboardingId: 'instance-tab-logs',
},
])
function updateBreadcrumbs() {
if (instance.value) {
breadcrumbs.setRootContext({
name: instance.value.name,
link: displayedInstanceRoute.value.path,
query: displayedInstanceRoute.value.query,
iconUrl: getDisplayInstanceIcon(instance.value.icon_path, instance.value.loader).url,
})
}
}
updateBreadcrumbs()
watch(
() => instance.value?.id,
() => {
updateBreadcrumbs()
},
)
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async () => {
if (!instance.value || loading.value) return
const id = props.id
const name = instance.value.name
if (!playing.value && !offline.value && updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
try {
const result = await run(id)
const gcNotice = result.gc_notice
if (gcNotice && gcReportFellBack(gcNotice)) {
addNotification({
type: 'warning',
title: formatMessage(messages.gcFallbackNotice, {
preferred: gcNotice.preferred_strategy || 'auto',
chosen: gcNotice.chosen_strategy || 'JVM default GC',
}),
})
}
await updatePlayState()
} catch (err) {
const handled = await handleMinecraftLaunchError(err, {
instance_id: id,
instance_name: name,
})
if (!handled) {
handleSevereError(err, { instanceId: id })
}
}
}
watch(
[loading, () => props.id],
([busy]) => {
clearInterval(launchElapsedTimer)
launchElapsedSeconds.value = 0
launchElapsedTimer = busy
? setInterval(() => {
launchElapsedSeconds.value += 1
}, 1000)
: undefined
},
{ immediate: true },
)
const stopInstance = async () => {
stopping.value = true
await kill(props.id).catch(handleError)
stopping.value = false
await updatePlayState()
if (!instance.value) return
}
const handlePlayServer = async () => {
if (!instance.value?.link?.project_id || loading.value) return
const id = props.id
serverLaunchingId.value = id
try {
await playServerProject(instance.value.link.project_id)
} finally {
await updatePlayState()
if (serverLaunchingId.value === id) serverLaunchingId.value = undefined
}
}
const repairInstance = async () => {
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(instance.value.id, {
type: 'fromVersionId',
project_id: instance.value.link.project_id ?? instance.value.link.server_project_id ?? '',
version_id: instance.value.link.version_id ?? instance.value.link.content_version_id ?? '',
title: instance.value.name,
}).catch(handleError)
} else {
await install_existing_instance(instance.value.id, false).catch(handleError)
}
}
const createShortcut = async () => {
if (!instance.value) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
addNotification({
type: 'success',
title: 'Shortcut created',
})
} catch (error: unknown) {
addNotification({
type: 'error',
title: `Error creating shortcut`,
text: `${error}`,
})
}
}
const openUpgrade = async () => {
if (!instance.value) return
const active = (await install_job_list(true).catch(() => [])).find((job) =>
isActiveUpgradeJobForInstance(job, instance.value!.id),
)
if (active) {
await router.push({ path: '/downloads', query: { job: active.job_id } })
return
}
await router.push(`/instance/${encodeURIComponent(instance.value.id)}/upgrade`)
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
]
options.value?.showMenu(
event,
instance.value,
playing.value
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...baseOptions,
],
)
}
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
switch (args.option) {
case 'play':
await startInstance()
break
case 'stop':
await stopInstance()
break
case 'add_content':
await router.push({
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.id },
})
break
case 'edit':
settingsModal.value?.show()
break
case 'open_folder':
if (instance.value) await showInstanceInFolder(instance.value.id)
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
break
}
}
}
const unlistenInstances = await instance_listener(
async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== props.id) return
if (event.event === 'removed' || route.path === '/') {
if (route.path !== '/') {
await router.push({ path: '/' })
}
return
}
instance.value = await get(props.id).catch((err) => {
if (String(err).includes('not managed')) {
router.push({ path: '/' })
return undefined
}
return handleError(err)
})
if (!instance.value?.link?.project_id) {
linkedProjectV3.value = undefined
isServerInstance.value = false
}
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
},
)
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
if (e.instance_id === props.id) {
void updatePlayState()
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
}
})
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const timePlayed = computed(() => {
return instance.value
? instance.value.recent_time_played + instance.value.submitted_time_played
: 0
})
const timePlayedHumanized = computed(() => {
const duration = dayjs.duration(timePlayed.value, 'seconds')
const hours = Math.floor(duration.asHours())
if (hours >= 1) {
return hours + ' hour' + (hours > 1 ? 's' : '')
}
const minutes = Math.floor(duration.asMinutes())
if (minutes >= 1) {
return minutes + ' minute' + (minutes > 1 ? 's' : '')
}
const seconds = Math.floor(duration.asSeconds())
return seconds + ' second' + (seconds > 1 ? 's' : '')
})
onUnmounted(() => {
clearInterval(launchElapsedTimer)
unlistenProcesses()
unlistenInstances()
const instanceId = displayedInstanceRoute.value.params.id
if (instanceId) {
const { destroy } = useInstanceConsole(instanceId)
destroy()
}
})
</script>
<style scoped lang="scss">
Button {
width: 100%;
}
.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;
}
.instance-container {
display: flex;
flex-direction: row;
overflow: auto;
gap: 1rem;
min-height: 100%;
padding: 1rem;
}
.badge {
display: flex;
align-items: center;
font-weight: bold;
width: fit-content;
color: var(--color-orange);
}
.pages-list {
.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%;
}
.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);
}
}
@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>
<style>
.app-viewport:has(.instance-fixed-render) {
scrollbar-gutter: auto;
}
.app-viewport:has(.instance-fixed-render) .page-transition-grid,
.app-viewport:has(.instance-fixed-render) .page-transition-layer {
height: 100%;
min-height: 0;
}
</style>