fix: ux changes for sync settings/overrides

This commit is contained in:
Calum H. (IMB11)
2026-08-28 10:07:40 +01:00
parent a9b774005b
commit 98aadb1f6a
17 changed files with 934 additions and 513 deletions
@@ -429,10 +429,10 @@ const groupedScreenshots = computed((): ScreenshotGroupData[] => {
screenshotGroups.set(screenshot.instance_id, group)
}
const syncedInstances = (instancesQuery.data.value ?? [])
.filter((instance) => instance.synced_options.screenshots)
.sort((a, b) => a.name.localeCompare(b.name))
const groups = syncedInstances.flatMap((instance) => {
const instances = [...(instancesQuery.data.value ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
const groups = instances.flatMap((instance) => {
const instanceScreenshots = screenshotGroups.get(instance.id)
return instanceScreenshots
? [
@@ -7,6 +7,7 @@ import {
useSavable,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
@@ -14,7 +15,13 @@ import {
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import {
get_global_synced_options,
type GlobalSyncedOptions,
set_global_synced_option,
} from '@/helpers/instance.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { screenshotKeys } from '@/pages/instance/query-options.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const appSettings = useAppSettings()
@@ -22,6 +29,7 @@ const { formatMessage } = useVIntl()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const queryClient = useQueryClient()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards'
@@ -51,6 +59,14 @@ const messages = defineMessages({
id: 'app.behavior-settings.content.title',
defaultMessage: 'Home and content',
},
showAllScreenshotsTitle: {
id: 'app.behavior-settings.show-all-screenshots.title',
defaultMessage: 'Show all screenshots together',
},
showAllScreenshotsDescription: {
id: 'app.behavior-settings.show-all-screenshots.description',
defaultMessage: 'View screenshots from all your instances on the Screenshots page.',
},
confirmationsTitle: {
id: 'app.behavior-settings.confirmations.title',
defaultMessage: 'Confirmations',
@@ -137,6 +153,7 @@ type BehaviorSettingsState = {
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
showAllScreenshots: boolean
compactInstanceCards: boolean
showPlayTime: boolean
hideNametag: boolean
@@ -144,14 +161,23 @@ type BehaviorSettingsState = {
skipNonEssentialWarnings: boolean
}
const persistedSettings = ref(await get())
const [initialSettings, initialGlobalSyncedOptions] = await Promise.all([
get(),
get_global_synced_options(),
])
const persistedSettings = ref(initialSettings)
const persistedGlobalSyncedOptions = ref(initialGlobalSyncedOptions)
function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState {
function getBehaviorSettingsState(
settings: AppSettings,
globalSyncedOptions: GlobalSyncedOptions,
): BehaviorSettingsState {
return {
syncBehaviorAcrossDevices: settings.sync_behavior_across_devices,
minimizeApp: settings.hide_on_process_start,
hideRightSidebar: settings.toggle_sidebar,
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
showAllScreenshots: globalSyncedOptions.screenshots,
compactInstanceCards:
settings.feature_flags[compactInstanceCardsFlag] ??
DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag],
@@ -169,7 +195,7 @@ function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getBehaviorSettingsState(persistedSettings.value),
() => getBehaviorSettingsState(persistedSettings.value, persistedGlobalSyncedOptions.value),
async () => {
const value = current.value
@@ -204,8 +230,20 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
},
}
await set(nextSettings)
const screenshotsChanged =
value.showAllScreenshots !== persistedGlobalSyncedOptions.value.screenshots
const [, updatedGlobalSyncedOptions] = await Promise.all([
set(nextSettings),
screenshotsChanged
? set_global_synced_option('screenshots', value.showAllScreenshots)
: Promise.resolve(persistedGlobalSyncedOptions.value),
])
persistedSettings.value = nextSettings
persistedGlobalSyncedOptions.value = updatedGlobalSyncedOptions
queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions)
if (screenshotsChanged) {
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
}
appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices)
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
@@ -302,6 +340,18 @@ onBeforeUnmount(() => {
{{ formatMessage(messages.contentTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showAllScreenshotsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.showAllScreenshotsDescription) }}
</p>
</div>
<Toggle id="show-all-screenshots" v-model="current.showAllScreenshots" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
@@ -2,11 +2,15 @@
import {
EditIcon,
// FolderOpenIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
CheckCircleButton,
commonMessages,
defineMessages,
IconButton,
@@ -26,7 +30,9 @@ import useMemorySlider from '@/composables/useMemorySlider'
import {
get_command_history,
get_global_synced_options,
getInstanceIconUrl,
type GlobalSyncedOptions,
list as listInstances,
list_synced_servers,
// open_synced_options_folder,
remove_synced_server,
@@ -43,7 +49,7 @@ import {
type ServerData,
type ServerWorld,
} from '@/helpers/worlds.ts'
import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -83,13 +89,33 @@ const messages = defineMessages({
id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Sync saved creative hotbars across your instances.',
},
screenshots: {
id: 'app.settings.synced-options.screenshots',
defaultMessage: 'Screenshots',
chooseSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.title',
defaultMessage: 'Choose a sync source',
},
screenshotsDescription: {
id: 'app.settings.synced-options.screenshots.description',
defaultMessage: 'View screenshots from your instances in one place.',
multiplayerServersSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
defaultMessage: 'Pick the instance whose multiplayer servers become the shared copy.',
},
commandHistorySyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
defaultMessage: 'Pick the instance whose command history becomes the shared copy.',
},
creativeHotbarsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
defaultMessage: 'Pick the instance whose saved creative hotbars become the shared copy.',
},
searchInstance: {
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
defaultMessage: 'Search instance',
},
noInstancesFound: {
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
defaultMessage: 'No instances found',
},
syncButton: {
id: 'app.settings.synced-options.choose-sync-source.sync',
defaultMessage: 'Sync',
},
commandHistoryEditorTitle: {
id: 'app.settings.synced-options.command-history.editor-title',
@@ -285,11 +311,6 @@ const globalRows: Array<{
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
{
option: 'screenshots',
title: 'screenshots',
description: 'screenshotsDescription',
},
]
const globalSyncedOptionsQueryKey = ['global-synced-options'] as const
@@ -306,6 +327,11 @@ const globalOptionsQuery = useQuery({
queryFn: get_global_synced_options,
})
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
const instances = ref(await listInstances().catch(() => []))
const baseOption = ref<SyncedOption | null>(null)
const baseInstanceId = ref(instances.value[0]?.id ?? '')
const baseInstanceSearch = ref('')
const baseModal = ref<InstanceType<typeof NewModal> | null>(null)
const commandHistoryModal = ref<InstanceType<typeof NewModal> | null>(null)
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
@@ -340,24 +366,43 @@ const syncedServerCards = computed(() =>
})),
)
const baseInstanceDescription = computed(() => {
switch (baseOption.value) {
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceDescription)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceDescription)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
default:
return ''
}
})
const filteredBaseInstances = computed(() => {
const search = baseInstanceSearch.value.trim().toLowerCase()
if (!search) return instances.value
return instances.value.filter((instance) => instance.name.toLowerCase().includes(search))
})
async function invalidateSyncedOptions() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: globalSyncedOptionsQueryKey }),
queryClient.invalidateQueries({ queryKey: screenshotKeys.all }),
])
}
type GlobalOptionMutationVariables = {
option: SyncedOption
enabled: boolean
baseInstanceId?: string
}
const globalOptionMutation = useMutation({
mutationKey: globalSyncedOptionsMutationKey,
mutationFn: ({ option, enabled }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled),
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled, baseInstanceId),
onMutate: async ({ option, enabled }) => {
await queryClient.cancelQueries({ queryKey: globalSyncedOptionsQueryKey })
const previous = globalOptions.value[option]
@@ -376,6 +421,15 @@ const globalOptionMutation = useMutation({
}))
handleError(error)
},
onSuccess: async (_options, { option, enabled }) => {
if (enabled) baseModal.value?.hide()
if (enabled && option === 'multiplayer_servers') {
syncedServers.value = await list_synced_servers().catch((error) => {
handleError(error)
return []
})
}
},
onSettled: async () => {
if (queryClient.isMutating({ mutationKey: globalSyncedOptionsMutationKey }) === 1) {
await invalidateSyncedOptions()
@@ -383,12 +437,25 @@ const globalOptionMutation = useMutation({
},
})
function applyGlobalOption(option: SyncedOption, enabled: boolean) {
globalOptionMutation.mutate({ option, enabled })
function applyGlobalOption(option: SyncedOption, enabled: boolean, baseInstanceId?: string) {
globalOptionMutation.mutate({ option, enabled, baseInstanceId })
}
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
applyGlobalOption(option, enabled)
if (!enabled) {
applyGlobalOption(option, false)
return
}
baseOption.value = option
baseInstanceId.value = instances.value[0]?.id ?? ''
baseInstanceSearch.value = ''
baseModal.value?.show()
}
function confirmBaseInstance() {
if (!baseOption.value || !baseInstanceId.value) return
applyGlobalOption(baseOption.value, true, baseInstanceId.value)
}
async function openCommandHistoryEditor() {
@@ -502,6 +569,79 @@ watch(
<template>
<div>
<NewModal
ref="baseModal"
:header="formatMessage(messages.chooseSyncSourceTitle)"
no-padding
actions-divider
max-width="560px"
width="560px"
>
<p class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-primary">
{{ baseInstanceDescription }}
</p>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
<Input
v-model="baseInstanceSearch"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.searchInstance)"
class="shrink-0"
/>
<div
v-if="filteredBaseInstances.length === 0"
class="flex flex-1 items-center justify-center text-secondary"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div
v-else
role="radiogroup"
:aria-label="formatMessage(messages.chooseSyncSourceTitle)"
class="flex flex-col gap-1"
>
<CheckCircleButton
v-for="instance in filteredBaseInstances"
:key="instance.id"
:checked="baseInstanceId === instance.id"
class="h-10"
@click="baseInstanceId = instance.id"
>
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:alt="instance.name"
:tint-by="instance.id"
size="1.25rem"
no-shadow
/>
</span>
<span class="truncate">{{ instance.name }}</span>
</CheckCircleButton>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2 p-2">
<Button type="outlined" @click="baseModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!baseInstanceId || globalOptionMutation.isPending.value"
@click="confirmBaseInstance"
>
<RefreshCwIcon />
{{ formatMessage(messages.syncButton) }}
</Button>
</div>
</template>
</NewModal>
<NewModal
ref="commandHistoryModal"
:header="formatMessage(messages.commandHistoryEditorTitle)"
@@ -338,10 +338,12 @@ export async function get_global_synced_options(): Promise<GlobalSyncedOptions>
export async function set_global_synced_option(
option: SyncedOption,
enabled: boolean,
baseInstanceId?: string,
): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_set_global_synced_option', {
option,
enabled,
baseInstanceId,
})
}
+35 -23
View File
@@ -215,6 +215,12 @@
"app.behavior-settings.content.title": {
"message": "Home and content"
},
"app.behavior-settings.show-all-screenshots.description": {
"message": "View screenshots from all your instances on the Screenshots page."
},
"app.behavior-settings.show-all-screenshots.title": {
"message": "Show all screenshots together"
},
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
@@ -1691,6 +1697,27 @@
"app.settings.sidebar.label.instances": {
"message": "Instances"
},
"app.settings.synced-options.choose-sync-source.command-history-description": {
"message": "Pick the instance whose command history becomes the shared copy."
},
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": {
"message": "Pick the instance whose saved creative hotbars become the shared copy."
},
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": {
"message": "Pick the instance whose multiplayer servers become the shared copy."
},
"app.settings.synced-options.choose-sync-source.no-instances-found": {
"message": "No instances found"
},
"app.settings.synced-options.choose-sync-source.search-placeholder": {
"message": "Search instance"
},
"app.settings.synced-options.choose-sync-source.sync": {
"message": "Sync"
},
"app.settings.synced-options.choose-sync-source.title": {
"message": "Choose a sync source"
},
"app.settings.synced-options.command-history": {
"message": "Command history"
},
@@ -1727,12 +1754,6 @@
"app.settings.synced-options.multiplayer-servers.none-synced-yet": {
"message": "No servers synced yet"
},
"app.settings.synced-options.screenshots": {
"message": "Screenshots"
},
"app.settings.synced-options.screenshots.description": {
"message": "View screenshots from your instances in one place."
},
"app.settings.tabs.appearance": {
"message": "Appearance"
},
@@ -2678,8 +2699,8 @@
"instance.settings.tabs.synced-options.command-history.disabled-in-app": {
"message": "Command history syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.command-history.exclude-description": {
"message": "Exclude this instance from command history syncing."
"instance.settings.tabs.synced-options.command-history.override-description": {
"message": "Keep this instance's command history separate from synced command history."
},
"instance.settings.tabs.synced-options.creative-hotbars": {
"message": "Saved creative hotbars"
@@ -2687,8 +2708,8 @@
"instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app": {
"message": "Saved creative hotbar syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.creative-hotbars.exclude-description": {
"message": "Exclude this instance from saved creative hotbar syncing."
"instance.settings.tabs.synced-options.creative-hotbars.override-description": {
"message": "Keep this instance's saved creative hotbars separate from synced hotbars."
},
"instance.settings.tabs.synced-options.hotbars-conflict.backup-description": {
"message": "The version being replaced will be backed up before anything changes."
@@ -2711,23 +2732,14 @@
"instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app": {
"message": "Multiplayer server syncing is turned off in app settings."
},
"instance.settings.tabs.synced-options.multiplayer-servers.exclude-description": {
"message": "Exclude this instance from multiplayer server syncing."
"instance.settings.tabs.synced-options.multiplayer-servers.override-description": {
"message": "Keep this instance's multiplayer servers separate from synced servers."
},
"instance.settings.tabs.synced-options.open-app-settings": {
"message": "Open synced settings"
},
"instance.settings.tabs.synced-options.screenshots": {
"message": "Screenshots"
},
"instance.settings.tabs.synced-options.screenshots.disabled-in-app": {
"message": "Screenshots are turned off in app settings."
},
"instance.settings.tabs.synced-options.screenshots.exclude-description": {
"message": "Exclude this instances screenshots from the Screenshots page."
"message": "Manage synced settings"
},
"instance.settings.tabs.synced-options.shared-settings.description": {
"message": "Game settings can be shared between instances. Choose what to share in app settings."
"message": "Enable an override to keep a synced setting separate for this instance."
},
"instance.settings.tabs.window": {
"message": "Window"
@@ -7,7 +7,6 @@ import { get } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
import SettingsOptionsTransition from './settings-options-transition.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -28,6 +27,16 @@ const hooks = ref({
post_exit: hooksRaw.post_exit ?? '',
})
watch(overrideHooks, (enabled) => {
if (!enabled) {
hooks.value = {
pre_launch: globalSettings.hooks.pre_launch ?? '',
wrapper: globalSettings.hooks.wrapper ?? '',
post_exit: globalSettings.hooks.post_exit ?? '',
}
}
})
const editInstanceObject = computed(() => ({
hooks: overrideHooks.value
? {
@@ -139,62 +148,63 @@ const messages = defineMessages({
<Toggle id="override-launch-hooks" v-model="overrideHooks" />
</div>
<SettingsOptionsTransition :show="overrideHooks">
<div class="pt-6">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<Input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<div class="pt-6" :class="{ 'opacity-50': !overrideHooks }">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<Input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<Input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<Input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<Input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<Input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<div class="m-0 mt-6">
{{ formatMessage(messages.hookVariablesDescription) }}
</div>
<ul class="m-0 mt-2">
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
<div class="m-0 mt-6">
{{ formatMessage(messages.hookVariablesDescription) }}
</div>
</SettingsOptionsTransition>
<ul class="m-0 mt-2">
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
</div>
</div>
</template>
@@ -28,7 +28,6 @@ import { get, parseEnvVars, serializeEnvVars } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
import SettingsOptionsTransition from './settings-options-transition.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -42,11 +41,14 @@ const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(
const overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() => (overrideJavaInstall.value ? javaPath.value : ''))
const activePath = computed(() => javaPath.value)
const javaTestPath = computed(() => (overrideJavaInstall.value ? javaPath.value : ''))
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
} else if (!enabled) {
javaPath.value = optimalJava?.path ?? ''
}
})
@@ -57,7 +59,7 @@ const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
javaTestPath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
@@ -95,12 +97,30 @@ const envVars = ref(
)
const overrideMemorySettings = ref(!!instance.value.memory)
const memory = ref(instance.value.memory ?? globalSettings.memory)
const memory = ref(instance.value.memory ?? { ...globalSettings.memory })
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(overrideJavaArgs, (enabled) => {
if (!enabled) {
javaArgs.value = globalSettings.extra_launch_args.join(' ')
}
})
watch(overrideEnvVars, (enabled) => {
if (!enabled) {
envVars.value = serializeEnvVars(globalSettings.custom_env_vars)
}
})
watch(overrideMemorySettings, (enabled) => {
if (!enabled) {
memory.value = { ...globalSettings.memory }
}
})
const editInstanceObject = computed(() => {
return {
java_path:
@@ -198,90 +218,89 @@ const messages = defineMessages({
</div>
<Toggle id="override-java-installation" v-model="overrideJavaInstall" />
</div>
<SettingsOptionsTransition :show="overrideJavaInstall">
<div class="pt-3">
<div class="flex gap-4 rounded-2xl bg-bg p-4">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
<div class="pt-3" :class="{ 'opacity-50': !overrideJavaInstall }">
<div class="flex gap-4 rounded-2xl bg-bg p-4">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
<div class="flex gap-2 items-center">
<Input
:model-value="activePath"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<Button
type="quiet"
:color="
!hoveringTest && !testingJava
<div class="flex gap-2 items-center">
<Input
:model-value="activePath"
autocomplete="off"
:disabled="!overrideJavaInstall"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<Button
type="quiet"
:color="
overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: undefined
"
:disabled="!overrideJavaInstall || testingJava"
:style="{
'--legacy-button-color':
(overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: undefined
"
:disabled="testingJava"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') &&
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="hoveringTest = true"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon
v-else-if="javaTestResult !== true && !hoveringTest"
class="h-4 w-4"
/>
<RefreshCwIcon v-else class="h-4 w-4" />
</Button>
</div>
<div class="flex gap-2">
<Button @click="handleDetectJava">
<SearchIcon />
Detect
</Button>
<Button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</Button>
</div>
: 'standard') &&
(overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
overrideJavaInstall && !hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="hoveringTest = true"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="overrideJavaInstall && javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon
v-else-if="overrideJavaInstall && javaTestResult !== true && !hoveringTest"
class="h-4 w-4"
/>
<RefreshCwIcon v-else class="h-4 w-4" />
</Button>
</div>
<div class="flex gap-2">
<Button :disabled="!overrideJavaInstall" @click="handleDetectJava">
<SearchIcon />
Detect
</Button>
<Button :disabled="!overrideJavaInstall" @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</Button>
</div>
</div>
</div>
</div>
</SettingsOptionsTransition>
</div>
</section>
<section class="flex flex-col">
@@ -294,20 +313,19 @@ const messages = defineMessages({
</div>
<Toggle id="override-memory-allocation" v-model="overrideMemorySettings" />
</div>
<SettingsOptionsTransition :show="overrideMemorySettings">
<div class="pt-3">
<Slider
id="max-memory"
v-model="memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
</div>
</SettingsOptionsTransition>
<div class="pt-3">
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
</div>
</section>
<section class="flex flex-col">
@@ -320,17 +338,16 @@ const messages = defineMessages({
</div>
<Toggle id="override-java-arguments" v-model="overrideJavaArgs" />
</div>
<SettingsOptionsTransition :show="overrideJavaArgs">
<div class="pt-3">
<Input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
</div>
</SettingsOptionsTransition>
<div class="pt-3" :class="{ 'opacity-50': !overrideJavaArgs }">
<Input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
</div>
</section>
<section class="flex flex-col">
@@ -343,17 +360,16 @@ const messages = defineMessages({
</div>
<Toggle id="override-environment-variables" v-model="overrideEnvVars" />
</div>
<SettingsOptionsTransition :show="overrideEnvVars">
<div class="pt-3">
<Input
id="env-vars"
v-model="envVars"
autocomplete="off"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
</SettingsOptionsTransition>
<div class="pt-3" :class="{ 'opacity-50': !overrideEnvVars }">
<Input
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
</section>
</div>
</template>
@@ -1,11 +1,5 @@
<script setup lang="ts">
import {
EditIcon,
RefreshCwIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { EditIcon, RefreshCwIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
@@ -17,7 +11,6 @@ import {
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, inject, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
get_synced_option_join_preview,
@@ -29,7 +22,7 @@ import {
import type { GameInstance } from '@/helpers/types'
import { appSettingsModalOpenSyncedOptionsKey } from '@/providers/app-settings-modal'
import { instanceKeys, screenshotKeys } from '../../query-options'
import { instanceKeys } from '../../query-options'
import HooksSettings from './hooks-settings.vue'
import { injectInstanceSettings } from './instance-settings-context'
import JavaSettings from './java-settings.vue'
@@ -39,27 +32,24 @@ const { instance, closeModal } = injectInstanceSettings()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const openAppSettingsSyncedOptions = inject(appSettingsModalOpenSyncedOptionsKey, () => {})
const messages = defineMessages({
sharedSettingsDescription: {
id: 'instance.settings.tabs.synced-options.shared-settings.description',
defaultMessage:
'Game settings can be shared between instances. Choose what to share in app settings.',
defaultMessage: 'Enable an override to keep a synced setting separate for this instance.',
},
openSyncedOptions: {
id: 'instance.settings.tabs.synced-options.open-app-settings',
defaultMessage: 'Open synced settings',
defaultMessage: 'Manage synced settings',
},
multiplayerServers: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers',
},
multiplayerServersDescription: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.exclude-description',
defaultMessage: 'Exclude this instance from multiplayer server syncing.',
id: 'instance.settings.tabs.synced-options.multiplayer-servers.override-description',
defaultMessage: "Keep this instance's multiplayer servers separate from synced servers.",
},
multiplayerServersDisabled: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app',
@@ -70,8 +60,8 @@ const messages = defineMessages({
defaultMessage: 'Command history',
},
commandHistoryDescription: {
id: 'instance.settings.tabs.synced-options.command-history.exclude-description',
defaultMessage: 'Exclude this instance from command history syncing.',
id: 'instance.settings.tabs.synced-options.command-history.override-description',
defaultMessage: "Keep this instance's command history separate from synced command history.",
},
commandHistoryDisabled: {
id: 'instance.settings.tabs.synced-options.command-history.disabled-in-app',
@@ -82,25 +72,13 @@ const messages = defineMessages({
defaultMessage: 'Saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'instance.settings.tabs.synced-options.creative-hotbars.exclude-description',
defaultMessage: 'Exclude this instance from saved creative hotbar syncing.',
id: 'instance.settings.tabs.synced-options.creative-hotbars.override-description',
defaultMessage: "Keep this instance's saved creative hotbars separate from synced hotbars.",
},
creativeHotbarsDisabled: {
id: 'instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app',
defaultMessage: 'Saved creative hotbar syncing is turned off in app settings.',
},
screenshots: {
id: 'instance.settings.tabs.synced-options.screenshots',
defaultMessage: 'Screenshots',
},
screenshotsDescription: {
id: 'instance.settings.tabs.synced-options.screenshots.exclude-description',
defaultMessage: 'Exclude this instances screenshots from the Screenshots page.',
},
screenshotsDisabled: {
id: 'instance.settings.tabs.synced-options.screenshots.disabled-in-app',
defaultMessage: 'Screenshots are turned off in app settings.',
},
hotbarConflictTitle: {
id: 'instance.settings.tabs.synced-options.hotbars-conflict.title',
defaultMessage: 'Choose creative hotbars',
@@ -124,15 +102,16 @@ const messages = defineMessages({
},
})
const globalDisabledMessages: Record<SyncedOption, keyof typeof messages> = {
type InstanceSyncedOption = Exclude<SyncedOption, 'screenshots'>
const globalDisabledMessages: Record<InstanceSyncedOption, keyof typeof messages> = {
multiplayer_servers: 'multiplayerServersDisabled',
command_history: 'commandHistoryDisabled',
creative_hotbars: 'creativeHotbarsDisabled',
screenshots: 'screenshotsDisabled',
}
const rows: Array<{
option: SyncedOption
option: InstanceSyncedOption
title: keyof typeof messages
description?: keyof typeof messages
}> = [
@@ -151,11 +130,6 @@ const rows: Array<{
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
{
option: 'screenshots',
title: 'screenshots',
description: 'screenshotsDescription',
},
]
const overviewQuery = useQuery(
@@ -173,16 +147,29 @@ const capabilities = computed(
),
)
const hotbarResolutionModal = ref<InstanceType<typeof NewModal> | null>(null)
const previewingOption = ref<SyncedOption | null>(null)
const previewingOption = ref<InstanceSyncedOption | null>(null)
const previewExcluded = ref<Partial<Record<InstanceSyncedOption, boolean>>>({})
function excluded(option: InstanceSyncedOption): boolean {
const preview = previewExcluded.value[option]
if (preview !== undefined) return preview
function excluded(option: SyncedOption): boolean {
return (
overviewQuery.data.value?.global_options[option] === true &&
!instance.value.synced_options[option]
)
}
function disabledReason(option: SyncedOption): string | undefined {
function setPreviewExcluded(option: InstanceSyncedOption, value?: boolean) {
if (value === undefined) {
const { [option]: _, ...next } = previewExcluded.value
previewExcluded.value = next
} else {
previewExcluded.value = { ...previewExcluded.value, [option]: value }
}
}
function disabledReason(option: InstanceSyncedOption): string | undefined {
if (overviewQuery.data.value?.global_options[option] === false) {
return formatMessage(messages[globalDisabledMessages[option]])
}
@@ -194,52 +181,97 @@ function showAppSyncedOptions(): void {
openAppSettingsSyncedOptions()
}
type SyncedOptionMutationVariables = {
option: InstanceSyncedOption
enabled: boolean
resolution?: SyncedOptionJoinResolution
}
const mutationKey = ['instance-synced-options', 'set', instance.value.id] as const
const mutation = useMutation({
mutationFn: ({
option,
enabled,
resolution,
}: {
option: SyncedOption
enabled: boolean
resolution?: SyncedOptionJoinResolution
}) => set_synced_option(instance.value.id, option, enabled, resolution),
onSuccess: async (updatedInstance, variables) => {
hotbarResolutionModal.value?.hide()
queryClient.setQueryData(instanceKeys.detail(updatedInstance.id), updatedInstance)
queryClient.setQueryData<GameInstance[]>(instanceKeys.list(), (instances) =>
mutationKey,
mutationFn: ({ option, enabled, resolution }: SyncedOptionMutationVariables) =>
set_synced_option(instance.value.id, option, enabled, resolution),
onMutate: async ({ option, enabled }) => {
const instanceId = instance.value.id
const detailKey = instanceKeys.detail(instanceId)
const listKey = instanceKeys.list()
await Promise.all([
queryClient.cancelQueries({ queryKey: detailKey }),
queryClient.cancelQueries({ queryKey: listKey }),
])
const previousEnabled = instance.value.synced_options[option]
const applyOption = (current: GameInstance): GameInstance => ({
...current,
synced_options: {
...current.synced_options,
[option]: enabled,
},
})
queryClient.setQueryData<GameInstance>(detailKey, (current) =>
applyOption(current ?? instance.value),
)
queryClient.setQueryData<GameInstance[]>(listKey, (instances) =>
instances?.map((candidate) =>
candidate.id === updatedInstance.id ? updatedInstance : candidate,
candidate.id === instanceId ? applyOption(candidate) : candidate,
),
)
await queryClient.invalidateQueries({
queryKey: ['instance-synced-options', updatedInstance.id],
})
setPreviewExcluded(option)
return { instanceId, previousEnabled }
},
onSuccess: () => {
hotbarResolutionModal.value?.hide()
},
onError: (error, { option }, context) => {
if (context) {
const rollbackOption = (current: GameInstance): GameInstance => ({
...current,
synced_options: {
...current.synced_options,
[option]: context.previousEnabled,
},
})
queryClient.setQueryData<GameInstance>(instanceKeys.detail(context.instanceId), (current) =>
current ? rollbackOption(current) : current,
)
queryClient.setQueryData<GameInstance[]>(instanceKeys.list(), (instances) =>
instances?.map((candidate) =>
candidate.id === context.instanceId ? rollbackOption(candidate) : candidate,
),
)
}
setPreviewExcluded(option)
handleError(error)
},
onSettled: async (_data, _error, variables) => {
if (variables.option === 'multiplayer_servers') {
await queryClient.invalidateQueries({
queryKey: instanceKeys.worlds(updatedInstance.id),
queryKey: instanceKeys.worlds(instance.value.id),
})
}
if (variables.option === 'screenshots') {
await queryClient.invalidateQueries({ queryKey: screenshotKeys.all })
if (updatedInstance.synced_options.screenshots && route.name === 'InstanceScreenshots') {
await router.replace(`/instance/${encodeURIComponent(updatedInstance.id)}`)
} else if (!updatedInstance.synced_options.screenshots && route.name === 'Screenshots') {
await router.replace('/')
}
if (queryClient.isMutating({ mutationKey }) === 1) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.detail(instance.value.id) }),
queryClient.invalidateQueries({ queryKey: instanceKeys.list() }),
queryClient.invalidateQueries({
queryKey: ['instance-synced-options', instance.value.id],
}),
])
}
},
onError: handleError,
})
async function setExcluded(option: SyncedOption, nextExcluded: boolean) {
async function setExcluded(option: InstanceSyncedOption, nextExcluded: boolean) {
const enabled = !nextExcluded
if (!enabled || option !== 'creative_hotbars') {
mutation.mutate({ option, enabled })
return
}
setPreviewExcluded(option, nextExcluded)
previewingOption.value = option
try {
const preview = await get_synced_option_join_preview(instance.value.id, option)
@@ -249,12 +281,18 @@ async function setExcluded(option: SyncedOption, nextExcluded: boolean) {
mutation.mutate({ option, enabled })
}
} catch (error) {
setPreviewExcluded(option)
handleError(error)
} finally {
previewingOption.value = null
}
}
function cancelHotbarResolution() {
setPreviewExcluded('creative_hotbars')
hotbarResolutionModal.value?.hide()
}
function resolveHotbars(resolution: SyncedOptionJoinResolution) {
mutation.mutate({
option: 'creative_hotbars',
@@ -271,6 +309,7 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
:header="formatMessage(messages.hotbarConflictTitle)"
fade="warning"
max-width="560px"
@hide="setPreviewExcluded('creative_hotbars')"
>
<div class="flex flex-col gap-3 text-primary">
<p class="m-0">
@@ -289,7 +328,7 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
<Button
type="outlined"
:disabled="mutation.isPending.value"
@click="hotbarResolutionModal?.hide()"
@click="cancelHotbarResolution"
>
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
@@ -336,20 +375,12 @@ function resolveHotbars(resolution: SyncedOptionJoinResolution) {
{{ formatMessage(messages[row.description]) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<SpinnerIcon
v-if="
(mutation.isPending.value && mutation.variables.value?.option === row.option) ||
previewingOption === row.option
"
class="size-5 animate-spin"
/>
<div class="flex shrink-0 items-center">
<span v-tooltip="disabledReason(row.option)" class="flex">
<Toggle
:id="`exclude-${row.option}`"
:model-value="excluded(row.option)"
:disabled="
mutation.isPending.value ||
previewingOption !== null ||
overviewQuery.isPending.value ||
!!disabledReason(row.option)
@@ -7,7 +7,6 @@ import { get } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
import SettingsOptionsTransition from './settings-options-transition.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -26,6 +25,13 @@ const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
watch(overrideWindowSettings, (enabled) => {
if (!enabled) {
resolution.value = globalSettings.game_resolution.slice() as [number, number]
fullscreenSetting.value = globalSettings.force_fullscreen
}
})
const editInstanceObject = computed(() => {
if (!overrideWindowSettings.value) {
return {
@@ -102,58 +108,56 @@ const messages = defineMessages({
</div>
<Toggle id="override-window-settings" v-model="overrideWindowSettings" />
</div>
<SettingsOptionsTransition :show="overrideWindowSettings">
<div class="flex flex-col gap-6 pt-6">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="fullscreenSetting" />
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<Input
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterWidth)"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<Input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
<div class="flex flex-col gap-6 pt-6" :class="{ 'opacity-50': !overrideWindowSettings }">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="fullscreenSetting" :disabled="!overrideWindowSettings" />
</div>
</SettingsOptionsTransition>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<Input
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterWidth)"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<Input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</div>
</div>
</template>
@@ -502,9 +502,7 @@ const tabs = computed(() => {
},
]
const screenshotsSynced =
globalSyncedOptionsQuery.data.value?.screenshots === true &&
instance.value?.synced_options.screenshots === true
const screenshotsSynced = globalSyncedOptionsQuery.data.value?.screenshots === true
if (!screenshotsSynced) {
instanceTabs.splice(2, 0, {
label: formatMessage(messages.screenshotsTab),
+7 -1
View File
@@ -839,8 +839,14 @@ pub async fn instance_get_global_synced_options()
pub async fn instance_set_global_synced_option(
option: InstanceSyncedOption,
enabled: bool,
base_instance_id: Option<String>,
) -> Result<theseus::instance::GlobalSyncedOptions> {
Ok(theseus::instance::set_global_synced_option(option, enabled).await?)
Ok(theseus::instance::set_global_synced_option(
option,
enabled,
base_instance_id.as_deref(),
)
.await?)
}
#[tauri::command]
@@ -0,0 +1,15 @@
UPDATE sync_feature_settings
SET globally_enabled = 0, new_instance_default = 1
WHERE feature IN (
'command_history',
'multiplayer_servers',
'creative_hotbars'
);
UPDATE instance_sync_preferences
SET enabled = 1
WHERE feature IN (
'command_history',
'multiplayer_servers',
'creative_hotbars'
);
@@ -67,9 +67,14 @@ pub async fn list_screenshots(
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
{
if !super::super::synced_options::get_global_options()
.await?
.screenshots
{
return Ok(Vec::new());
}
let state = State::get().await?;
let sources =
instance_rows::list_synced_screenshot_sources(&state.pool).await?;
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
list_source_screenshot_sets(&state, sources).await
}
@@ -288,47 +288,28 @@ async fn version_capability(
pub async fn set_global_option(
option: SyncedOption,
enabled: bool,
base_instance_id: Option<&str>,
) -> crate::Result<GlobalSyncedOptions> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
let reset_participation =
enabled && !canonical_exists(option, &state).await?;
let option_name = option.as_str();
sqlx::query!(
"
INSERT INTO sync_feature_settings
(feature, globally_enabled, new_instance_default)
VALUES (?, ?, 1)
ON CONFLICT(feature) DO UPDATE SET
globally_enabled = excluded.globally_enabled
",
option_name,
enabled,
)
.execute(&state.pool)
.await?;
if enabled && option != SyncedOption::Screenshots {
let base_instance_id = base_instance_id.ok_or_else(|| {
ErrorKind::InputError(
"Choose an instance to use as the sync source.".to_string(),
)
})?;
return enable_global_option_from_base(
option,
base_instance_id,
&state,
)
.await;
}
set_global_option_enabled(option, enabled, &state).await?;
let instances = crate::state::list_instances(&state.pool).await?;
if reset_participation {
for metadata in instances {
if instance_option_enabled(&metadata, option) {
instance_rows::set_instance_sync_preference(
&metadata.instance.id,
option,
false,
&state.pool,
)
.await?;
}
if !sync_files_are_protected(&metadata)
&& !instance_is_running(&metadata, &state).await?
{
detach_option(&metadata, option, &state).await?;
}
}
return get_global_options_with_state(&state).await;
}
for metadata in instances {
if sync_files_are_protected(&metadata)
|| instance_is_running(&metadata, &state).await?
@@ -353,6 +334,112 @@ pub async fn set_global_option(
get_global_options_with_state(&state).await
}
async fn set_global_option_enabled(
option: SyncedOption,
enabled: bool,
state: &State,
) -> crate::Result<()> {
let option_name = option.as_str();
sqlx::query!(
"
INSERT INTO sync_feature_settings
(feature, globally_enabled, new_instance_default)
VALUES (?, ?, 1)
ON CONFLICT(feature) DO UPDATE SET
globally_enabled = excluded.globally_enabled
",
option_name,
enabled,
)
.execute(&state.pool)
.await?;
Ok(())
}
async fn enable_global_option_from_base(
option: SyncedOption,
base_instance_id: &str,
state: &State,
) -> crate::Result<GlobalSyncedOptions> {
let source = crate::state::get_instance(base_instance_id, &state.pool)
.await?
.ok_or_else(|| {
ErrorKind::InputError("Unknown sync source instance.".to_string())
})?;
if sync_files_are_protected(&source)
|| instance_is_running(&source, state).await?
{
return Err(ErrorKind::InputError(
"Close the source instance before using it for syncing."
.to_string(),
)
.into());
}
match capability_status(&source, option, true, state).await {
CapabilityStatus::Supported => {}
CapabilityStatus::Unsupported(reason)
| CapabilityStatus::Indeterminate(reason) => {
return Err(ErrorKind::InputError(reason).into());
}
}
let instances = crate::state::list_instances(&state.pool).await?;
for metadata in &instances {
if instance_option_enabled(metadata, option)
&& (sync_files_are_protected(metadata)
|| instance_is_running(metadata, state).await?)
{
return Err(ErrorKind::InputError(
"Close all instances using this synced setting before choosing a new sync source."
.to_string(),
)
.into());
}
}
for metadata in &instances {
if instance_option_enabled(metadata, option) {
detach_option(metadata, option, state).await?;
}
}
if !instance_option_enabled(&source, option) {
detach_option(&source, option, state).await?;
}
seed_from_instance(&source, option, state).await?;
instance_rows::set_instance_sync_preference(
base_instance_id,
option,
true,
&state.pool,
)
.await?;
set_global_option_enabled(option, true, state).await?;
for metadata in crate::state::list_instances(&state.pool).await? {
if sync_files_are_protected(&metadata)
|| instance_is_running(&metadata, state).await?
{
continue;
}
if !instance_option_enabled(&metadata, option) {
detach_option(&metadata, option, state).await?;
continue;
}
match capability_status(&metadata, option, true, state).await {
CapabilityStatus::Supported => {
ensure_option(&metadata, option, state).await?
}
CapabilityStatus::Unsupported(_) => {
detach_option(&metadata, option, state).await?
}
CapabilityStatus::Indeterminate(_) => {}
}
}
get_global_options_with_state(state).await
}
pub async fn set_instance_option(
instance_id: &str,
option: SyncedOption,
@@ -172,7 +172,48 @@ pub(in crate::api::instance) async fn detach_servers(
) -> crate::Result<()> {
let generated = generated_path(state, &metadata.instance.id);
let local = instance_dir(metadata, state).join(SERVERS_FILE);
detach_link(&generated, &local).await
let Some(current_checkpoint) = checkpoint(
&metadata.instance.id,
SyncedOption::MultiplayerServers,
"default",
state,
)
.await?
else {
return detach_link(&generated, &local).await;
};
let linked_to_generated = tokio::fs::symlink_metadata(&local)
.await
.is_ok_and(|metadata| metadata.file_type().is_symlink())
&& tokio::fs::read_link(&local)
.await
.is_ok_and(|target| target == generated);
let matches_checkpoint = local.exists()
&& sha1_file(&local).await? == current_checkpoint.expected_sha1;
if current_checkpoint.status != CheckpointStatus::Ready
|| (!linked_to_generated && !matches_checkpoint)
{
return detach_link(&generated, &local).await;
}
let current = read_servers(&local).await?;
let projections =
load_projection_entries(&metadata.instance.id, state).await?;
let projection_matches = match_projection_entries(&current, &projections);
let instance_servers = current
.into_iter()
.zip(projection_matches)
.filter_map(|(server, projection)| {
projection
.is_none_or(|projection| {
projection.owner == ProjectionOwner::Instance
})
.then_some(server)
})
.collect::<Vec<_>>();
detach_link(&generated, &local).await?;
write_servers(&local, &instance_servers).await
}
pub(in crate::api::instance) async fn reconcile_servers(
@@ -494,32 +494,6 @@ pub(crate) async fn get_instance_screenshot_source(
Ok(source)
}
pub(crate) async fn list_synced_screenshot_sources(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceScreenshotSource>> {
let sources = sqlx::query_as!(
InstanceScreenshotSource,
"
SELECT instances.id, instances.name, instances.path
FROM instances
INNER JOIN instance_sync_preferences preferences
ON preferences.instance_id = instances.id
WHERE preferences.feature = 'screenshots'
AND preferences.enabled = 1
AND EXISTS (
SELECT 1
FROM sync_feature_settings
WHERE feature = 'screenshots' AND globally_enabled = 1
)
ORDER BY instances.name, instances.id
",
)
.fetch_all(pool)
.await?;
Ok(sources)
}
pub(crate) async fn list_screenshot_sources(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceScreenshotSource>> {
+123 -93
View File
@@ -1,25 +1,29 @@
<template>
<div class="flex flex-row items-center w-full">
<div class="w-full relative">
<div class="absolute top-0 h-1/2 w-full">
<div
class="relative inline-block align-middle w-[calc(100%-0.75rem)] h-3 left-[calc(0.75rem/2)]"
>
<div
v-for="snapPoint in snapPoints"
:key="snapPoint"
class="absolute inline-block w-1 h-full rounded-sm -translate-x-1/2"
:class="{
'opacity-0': disabled,
}"
:style="{
left: ((snapPoint - min) / (max - min)) * 100 + '%',
backgroundColor:
snapPoint <= currentValue ? 'var(--color-brand)' : 'var(--color-base)',
}"
></div>
</div>
<div class="flex w-full items-center gap-4">
<span class="shrink-0 whitespace-nowrap py-2 text-sm leading-5 text-secondary">
{{ min }}
</span>
<div class="relative h-10 min-w-0 flex-1" :class="disabled ? 'opacity-50' : ''">
<div
class="pointer-events-none absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-surface-5"
>
<div class="h-full rounded-full bg-brand" :style="{ width: `${currentPercentage}%` }" />
</div>
<div
v-if="visibleSnapPoints.length"
class="pointer-events-none absolute inset-x-0 top-1/2 h-6 -translate-y-1/2"
>
<span
v-for="snapPoint in visibleSnapPoints"
:key="snapPoint"
class="absolute top-0 h-6 w-1 -translate-x-1/2 rounded-full"
:class="snapPoint <= currentValue ? 'bg-brand' : 'bg-surface-5'"
:style="{ left: `${getPercentage(snapPoint)}%` }"
/>
</div>
<input
ref="input"
v-model="currentValue"
@@ -27,27 +31,24 @@
:min="min"
:max="max"
:step="step"
class="slider relative rounded-sm h-1 w-full p-0 min-h-0 shadow-none outline-none align-middle appearance-none"
:class="{
'opacity-50 cursor-not-allowed': disabled,
}"
class="slider absolute top-0 h-10 min-h-0 appearance-none border-0 bg-transparent p-0 shadow-none outline-none"
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
:disabled="disabled"
:style="{
'--current-value': currentValue,
'--min-value': min,
'--max-value': max,
}"
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
/>
<div class="flex flex-row justify-between text-xs m-0">
<span> {{ min }} {{ unit }} </span>
<span> {{ max }} {{ unit }} </span>
</div>
</div>
<span class="shrink-0 whitespace-nowrap py-2 text-sm leading-5 text-secondary">
{{ formatValue(max) }}
</span>
<Input
:model-value="String(currentValue)"
type="number"
class="w-24 ml-3"
size="medium"
wrapper-class="slider-value shrink-0"
input-class="!font-semibold"
:style="{ width: valueInputWidth }"
:disabled="disabled"
:min="min"
:max="max"
@@ -58,7 +59,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import Input from './inputs/Input.vue'
@@ -88,104 +89,133 @@ const props = withDefaults(defineProps<Props>(), {
unit: '',
})
const currentValue = ref(Math.max(props.min, props.modelValue))
const currentValue = ref(clampValue(props.modelValue))
const currentPercentage = computed(() => getPercentage(currentValue.value))
const valueInputWidth = computed(
() => `calc(${Math.max(String(currentValue.value).length, 1)}ch + 2.125rem)`,
)
const visibleSnapPoints = computed(() =>
props.snapPoints.filter((snapPoint) => snapPoint >= props.min && snapPoint <= props.max),
)
watch(
() => props.modelValue,
(newValue) => {
currentValue.value = Math.max(props.min, newValue ?? props.min)
currentValue.value = clampValue(newValue ?? props.min)
},
)
const inputValueValid = (inputValue: number) => {
let newValue = inputValue || props.min
function clampValue(value: number) {
return Math.max(props.min, Math.min(value, props.max))
}
if (props.forceStep) {
function getPercentage(value: number) {
const range = props.max - props.min
if (range <= 0) return 0
return Math.max(0, Math.min(((value - props.min) / range) * 100, 100))
}
function formatValue(value: number) {
return props.unit ? `${value} ${props.unit}` : String(value)
}
function inputValueValid(inputValue: number) {
if (Number.isNaN(inputValue)) return
let newValue = inputValue
if (props.forceStep && props.step > 0) {
newValue -= newValue % props.step
}
newValue = Math.max(props.min, Math.min(newValue, props.max))
currentValue.value = newValue
currentValue.value = clampValue(newValue)
emit('update:modelValue', currentValue.value)
}
const onInputWithSnap = (value: string) => {
let parsedValue = parseInt(value)
function onInputWithSnap(value: string) {
let parsedValue = Number.parseFloat(value)
for (const snapPoint of props.snapPoints) {
const distance = Math.abs(snapPoint - parsedValue)
if (distance < props.snapRange) {
parsedValue = snapPoint
}
if (distance < props.snapRange) parsedValue = snapPoint
}
inputValueValid(parsedValue)
}
const onInput = (value: string) => {
inputValueValid(parseInt(value))
function onInput(value: string) {
inputValueValid(Number.parseFloat(value))
}
</script>
<style lang="scss" scoped>
.slider {
-webkit-appearance: none;
appearance: none;
background: linear-gradient(
to right,
var(--color-brand) 0%,
var(--color-brand)
calc(
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
),
var(--color-base)
calc(
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
),
var(--color-base) 100%
)
100% 100% no-repeat;
left: -0.625rem;
width: calc(100% + 1.25rem);
&::-webkit-slider-runnable-track {
height: 0.25rem;
background: transparent;
}
&::-moz-range-track,
&::-moz-range-progress {
height: 0.25rem;
background: transparent;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 0.75rem;
height: 0.75rem;
background: var(--color-brand);
border-radius: 50%;
transition:
width 0.2s,
height 0.2s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
width: 1.25rem;
height: 1.25rem;
margin-top: -0.5rem;
border: 0;
border-radius: 9999px;
background: var(--color-text-default);
box-shadow:
0 0 0 2px var(--surface-3),
0 0 0 4px var(--color-brand);
}
&::-moz-range-thumb {
border: none;
width: 0.75rem;
height: 0.75rem;
background: var(--color-brand);
border-radius: 50%;
transition:
width 0.2s,
height 0.2s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
width: 1.25rem;
height: 1.25rem;
border: 0;
border-radius: 9999px;
background: var(--color-text-default);
box-shadow:
0 0 0 2px var(--surface-3),
0 0 0 4px var(--color-brand);
}
&:hover:not(:disabled)::-webkit-slider-thumb,
&:hover:not(:disabled)::-moz-range-thumb {
width: 1rem;
height: 1rem;
&:focus-visible::-webkit-slider-thumb {
box-shadow:
0 0 0 2px var(--surface-3),
0 0 0 4px var(--color-brand),
0 0 0 8px var(--color-brand-highlight);
}
&:focus-visible::-moz-range-thumb {
box-shadow:
0 0 0 2px var(--surface-3),
0 0 0 4px var(--color-brand),
0 0 0 8px var(--color-brand-highlight);
}
&:disabled {
pointer-events: none;
opacity: 1;
}
}
.slider-value :deep(input[type='number']) {
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
margin: 0;
-webkit-appearance: none;
}
}
</style>