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
@@ -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>