diff --git a/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue index 548abba7af..e137305098 100644 --- a/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue @@ -23,10 +23,15 @@ import { } from '@modrinth/ui' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { Component } from 'vue' -import { computed, ref, shallowRef, watch } from 'vue' +import { computed, nextTick, ref, shallowRef, watch } from 'vue' +import GameSettingsModal from '@/components/ui/settings/instances/game-settings/GameSettingsModal.vue' import WorldItem from '@/components/ui/world/WorldItem.vue' import useMemorySlider from '@/composables/useMemorySlider' +import { + type GameOptionsSourceCandidate, + list_game_options_sync_sources, +} from '@/helpers/game-options' import { get_command_history, get_global_synced_options, @@ -65,6 +70,23 @@ const messages = defineMessages({ // id: 'app.settings.synced-options.folder', // defaultMessage: 'Synced folder', // }, + gameSettings: { + id: 'app.settings.synced-options.game-settings', + defaultMessage: 'Sync game options', + }, + gameSettingsDescription: { + id: 'app.settings.synced-options.game-settings.description', + defaultMessage: + 'Use the same graphics, keybinds, and other game settings across your instances.', + }, + gameSettingsButton: { + id: 'app.settings.synced-options.game-settings.button', + defaultMessage: 'Edit game settings', + }, + gameSettingsDisabledTooltip: { + id: 'app.settings.synced-options.game-settings.disabled-tooltip', + defaultMessage: 'Enable game settings sync before choosing individual settings.', + }, multiplayerServers: { id: 'app.settings.synced-options.multiplayer-servers', defaultMessage: 'Sync multiplayer servers', @@ -105,6 +127,10 @@ const messages = defineMessages({ id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description', defaultMessage: 'Pick the instance whose saved creative hotbars become the shared copy.', }, + gameSettingsSyncSourceDescription: { + id: 'app.settings.synced-options.choose-sync-source.game-settings-description', + defaultMessage: 'Choose which instance to copy game settings from.', + }, searchInstance: { id: 'app.settings.synced-options.choose-sync-source.search-placeholder', defaultMessage: 'Search instance', @@ -292,8 +318,14 @@ const globalRows: Array<{ option: SyncedOption title: keyof typeof messages description?: keyof typeof messages - editable?: 'servers' | 'commands' + editable?: 'game-settings' | 'servers' | 'commands' }> = [ + { + option: 'game_options', + title: 'gameSettings', + description: 'gameSettingsDescription', + editable: 'game-settings', + }, { option: 'multiplayer_servers', title: 'multiplayerServers', @@ -316,6 +348,7 @@ const globalRows: Array<{ const globalSyncedOptionsQueryKey = ['global-synced-options'] as const const globalSyncedOptionsMutationKey = ['global-synced-options', 'set'] as const const defaultGlobalOptions: GlobalSyncedOptions = { + game_options: false, command_history: false, multiplayer_servers: false, creative_hotbars: false, @@ -332,6 +365,7 @@ const baseOption = ref(null) const baseInstanceId = ref(instances.value[0]?.id ?? '') const baseInstanceSearch = ref('') const baseModal = ref | null>(null) +const gameSettingsModal = ref | null>(null) const commandHistoryModal = ref | null>(null) const serverEditorModal = ref | null>(null) const editServerModal = ref | null>(null) @@ -345,6 +379,16 @@ const syncedServers = ref( const editedServer = ref(null) const serverData = ref>({}) const editorComponent = shallowRef(null) +const gameOptionSources = ref([]) +const baseSourcesLoading = ref(false) +let baseSourceGeneration = 0 + +type BaseSourceCandidate = { + id: string + name: string + icon_path?: string | null + eligible: boolean +} const syncedServerCards = computed(() => syncedServers.value.map((server, index) => ({ @@ -368,6 +412,8 @@ const syncedServerCards = computed(() => const baseInstanceDescription = computed(() => { switch (baseOption.value) { + case 'game_options': + return formatMessage(messages.gameSettingsSyncSourceDescription) case 'multiplayer_servers': return formatMessage(messages.multiplayerServersSyncSourceDescription) case 'command_history': @@ -381,8 +427,22 @@ const baseInstanceDescription = computed(() => { const filteredBaseInstances = computed(() => { const search = baseInstanceSearch.value.trim().toLowerCase() - if (!search) return instances.value - return instances.value.filter((instance) => instance.name.toLowerCase().includes(search)) + const candidates: BaseSourceCandidate[] = + baseOption.value === 'game_options' + ? gameOptionSources.value.map((source) => ({ + id: source.source_id, + name: source.name, + icon_path: source.icon_path, + eligible: source.eligible, + })) + : instances.value.map((instance) => ({ + id: instance.id, + name: instance.name, + icon_path: instance.icon_path, + eligible: true, + })) + if (!search) return candidates + return candidates.filter((instance) => instance.name.toLowerCase().includes(search)) }) async function invalidateSyncedOptions() { @@ -407,10 +467,12 @@ const globalOptionMutation = useMutation({ await queryClient.cancelQueries({ queryKey: globalSyncedOptionsQueryKey }) const previous = globalOptions.value[option] - queryClient.setQueryData(globalSyncedOptionsQueryKey, (current) => ({ - ...(current ?? defaultGlobalOptions), - [option]: enabled, - })) + if (option !== 'game_options' || !enabled) { + queryClient.setQueryData(globalSyncedOptionsQueryKey, (current) => ({ + ...(current ?? defaultGlobalOptions), + [option]: enabled, + })) + } return { previous } }, @@ -421,8 +483,11 @@ const globalOptionMutation = useMutation({ })) handleError(error) }, - onSuccess: async (_options, { option, enabled }) => { - if (enabled) baseModal.value?.hide() + onSuccess: async (options, { option, enabled }) => { + queryClient.setQueryData(globalSyncedOptionsQueryKey, options) + if (option === 'game_options') { + await refreshSettings() + } if (enabled && option === 'multiplayer_servers') { syncedServers.value = await list_synced_servers().catch((error) => { handleError(error) @@ -441,21 +506,71 @@ function applyGlobalOption(option: SyncedOption, enabled: boolean, baseInstanceI globalOptionMutation.mutate({ option, enabled, baseInstanceId }) } -function toggleGlobalOption(option: SyncedOption, enabled: boolean) { - if (!enabled) { - applyGlobalOption(option, false) +async function chooseBaseInstance(option: SyncedOption) { + const generation = ++baseSourceGeneration + baseOption.value = option + baseInstanceSearch.value = '' + baseSourcesLoading.value = option === 'game_options' + + if (option === 'game_options') { + try { + const sources = await list_game_options_sync_sources() + if (generation !== baseSourceGeneration || baseOption.value !== option) return + gameOptionSources.value = sources + baseInstanceId.value = + gameOptionSources.value.find((source) => source.eligible)?.source_id ?? '' + } catch (error) { + if (generation !== baseSourceGeneration || baseOption.value !== option) return + gameOptionSources.value = [] + baseInstanceId.value = '' + handleError(error) + return + } finally { + if (generation === baseSourceGeneration && baseOption.value === option) { + baseSourcesLoading.value = false + } + } + if (!baseInstanceId.value) { + applyGlobalOption(option, true) + return + } + baseModal.value?.show() return } - baseOption.value = option + if (instances.value.length === 0) { + applyGlobalOption(option, true) + return + } baseInstanceId.value = instances.value[0]?.id ?? '' - baseInstanceSearch.value = '' baseModal.value?.show() } -function confirmBaseInstance() { +function toggleGlobalOption(option: SyncedOption, enabled: boolean) { + if (enabled && option !== 'screenshots') { + void chooseBaseInstance(option) + return + } + applyGlobalOption(option, enabled) +} + +async function confirmBaseInstance() { if (!baseOption.value || !baseInstanceId.value) return - applyGlobalOption(baseOption.value, true, baseInstanceId.value) + try { + await globalOptionMutation.mutateAsync({ + option: baseOption.value, + enabled: true, + baseInstanceId: baseInstanceId.value, + }) + await nextTick() + baseModal.value?.hide() + } catch { + return + } +} + +function openGameSettings() { + gameSettingsModal.value?.show() } async function openCommandHistoryEditor() { @@ -536,11 +651,32 @@ async function removeSyncedServer(serverId: string) { } } -const fetchSettings = await get() -fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ') -fetchSettings.envVars = serializeEnvVars(fetchSettings.custom_env_vars) +function addEditableSettingsFields(fetchSettings: Awaited>) { + return Object.assign(fetchSettings, { + launchArgs: fetchSettings.extra_launch_args.join(' '), + envVars: serializeEnvVars(fetchSettings.custom_env_vars), + }) +} + +const settings = ref(addEditableSettingsFields(await get())) +let applyingFetchedSettings = false -const settings = ref(fetchSettings) +async function refreshSettings() { + try { + const refreshedSettings = addEditableSettingsFields(await get()) + applyingFetchedSettings = true + settings.value = refreshedSettings + await nextTick() + } catch (error) { + handleError(error) + } finally { + applyingFetchedSettings = false + } +} + +async function handleGameSettingsSaved() { + await Promise.all([invalidateSyncedOptions(), refreshSettings()]) +} const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as { maxMemory: number @@ -550,6 +686,7 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a watch( settings, async () => { + if (applyingFetchedSettings) return const setSettings = JSON.parse(JSON.stringify(settings.value)) setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean) @@ -569,6 +706,8 @@ watch(