diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue index 256a0d0121..6d41adb369 100644 --- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue +++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue @@ -5,7 +5,9 @@ import { commonMessages, ContextMenu, defineMessages, + injectAuth, injectNotificationManager, + injectUserPreferences, useVIntl, } from '@modrinth/ui' import { useQuery } from '@tanstack/vue-query' @@ -15,6 +17,7 @@ import { useRouter } from 'vue-router' import NavButton from '@/components/ui/NavButton.vue' import { useAppEvent } from '@/composables/use-app-event' +import { useAppSettings } from '@/composables/use-app-settings.ts' import { handleSevereError } from '@/composables/use-error.js' import { QUICK_INSTANCE_LIMIT_MAX, @@ -28,7 +31,11 @@ import { showInstanceInFolder } from '@/helpers/utils' import { instanceListQueryOptions } from '@/pages/instance/query-options' const ITEM_SIZE = 52 +const SYNC_DEBOUNCE_MS = 1000 const { handleError } = injectNotificationManager() +const auth = injectAuth() +const { updatePreferences } = injectUserPreferences() +const appSettings = useAppSettings() const instancesQuery = useQuery(instanceListQueryOptions()) const router = useRouter() const instanceOptions = ref() @@ -97,6 +104,41 @@ const setLimit = (count) => { } } +const syncLimitToAccount = () => { + if (!appSettings.syncFeaturesAcrossDevices || !auth.user.value) { + return + } + updatePreferences({ + behavior: { + quick_instance_count: quickInstances.limit.value ?? QUICK_INSTANCE_LIMIT_MAX, + }, + }).catch(() => undefined) +} + +let syncTimeout = null + +const cancelScheduledSync = () => { + if (syncTimeout !== null) { + clearTimeout(syncTimeout) + syncTimeout = null + } +} + +const scheduleSync = () => { + cancelScheduledSync() + syncTimeout = setTimeout(() => { + syncTimeout = null + syncLimitToAccount() + }, SYNC_DEBOUNCE_MS) +} + +const flushScheduledSync = () => { + if (syncTimeout !== null) { + cancelScheduledSync() + syncLimitToAccount() + } +} + const nudgeLimit = (delta) => { if (!canDrag.value) { return @@ -105,11 +147,17 @@ const nudgeLimit = (delta) => { if (target < 0 || target > maxVisible.value) { flashOverdrag() } + const previousLimit = quickInstances.limit.value setLimit(target) + if (quickInstances.limit.value !== previousLimit) { + scheduleSync() + } } let dragStartY = 0 let dragStartCount = 0 +let dragStartLimit = null +let dragHadPendingSync = false let wasOverdragging = false let overdragTimeout = null @@ -142,6 +190,9 @@ const onDividerPointerDown = (event) => { clearOverdragFlash() dragStartY = event.clientY dragStartCount = visibleCount.value + dragStartLimit = quickInstances.limit.value + dragHadPendingSync = syncTimeout !== null + cancelScheduledSync() document.body.classList.add('quick-instance-dragging') event.currentTarget.setPointerCapture(event.pointerId) } @@ -171,6 +222,9 @@ const endDrag = (event) => { if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } + if (dragHadPendingSync || quickInstances.limit.value !== dragStartLimit) { + syncLimitToAccount() + } } const onDividerPointerUp = (event) => { @@ -191,6 +245,7 @@ onMounted(() => { onUnmounted(() => { resizeObserver?.disconnect() + flushScheduledSync() document.body.classList.remove('quick-instance-dragging') clearOverdragFlash() }) diff --git a/packages/app-lib/src/state/instances/commands/apply_content_update.rs b/packages/app-lib/src/state/instances/commands/apply_content_update.rs index f890015030..e269803d02 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_update.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_update.rs @@ -117,13 +117,6 @@ async fn apply_content_update( update: &ContentUpdate, state: &State, ) -> crate::Result { - let enabled = content_rows::get_instance_file_by_relative_path( - instance_id, - project_path, - &state.pool, - ) - .await? - .is_none_or(|file| file.enabled); let downloaded = download_project_version( instance_id, &update.update_version_id, @@ -139,7 +132,7 @@ async fn apply_content_update( instance_id, downloaded, ContentSourceKind::Local, - Some(enabled), + None, Some(project_path), state, ) @@ -186,7 +179,23 @@ pub(crate) async fn apply_bulk_update( .await?; match download { DownloadedBulkProject::ProjectUpdate(update, downloaded) => { + let mut enabled = + content_rows::get_instance_file_by_relative_path( + instance_id, + &update.relative_path, + &state.pool, + ) + .await? + .is_none_or(|file| file.enabled); for path in &update.duplicate_paths { + enabled |= + content_rows::get_instance_file_by_relative_path( + instance_id, + path, + &state.pool, + ) + .await? + .is_some_and(|file| file.enabled); super::content_mutation::remove_project( instance_id, path, @@ -194,13 +203,6 @@ pub(crate) async fn apply_bulk_update( ) .await?; } - let enabled = content_rows::get_instance_file_by_relative_path( - instance_id, - &update.relative_path, - &state.pool, - ) - .await? - .is_none_or(|file| file.enabled); let new_path = add_downloaded_project_version_with_enabled( instance_id, downloaded, @@ -532,25 +534,22 @@ pub(crate) async fn plan_bulk_update( .insert(update.project_id.clone(), (update, Vec::new())); } } - let updates_by_path = updates_by_project + for (update, duplicate_paths) in updates_by_project.values_mut() { + adopt_current_copy( + update, + duplicate_paths, + &installed, + &updateable_paths, + ); + } + let planned_versions = updates_by_project .values() - .map(|(update, _)| { - ( - update.relative_path.clone(), - update.update_version_id.clone(), - ) + .filter(|(update, duplicate_paths)| { + is_enabled(&update.relative_path) + || duplicate_paths.iter().any(|path| is_enabled(path)) }) - .collect::>(); - let planned_versions = installed - .iter() - .filter(|project| project.enabled) - .filter(|project| updates_by_path.contains_key(&project.relative_path)) - .filter_map(|project| { - let target_version_id = updates_by_path - .get(&project.relative_path) - .or(project.version_id.as_ref())?; - - versions_by_id.get(target_version_id).cloned() + .filter_map(|(update, _)| { + versions_by_id.get(&update.update_version_id).cloned() }) .collect::>(); let planned_dependencies = @@ -591,6 +590,38 @@ pub(crate) async fn plan_bulk_update( }) } +fn adopt_current_copy( + update: &mut ContentUpdate, + duplicate_paths: &mut Vec, + installed: &[InstalledProject], + updateable_paths: &HashSet, +) { + let Some(current) = installed.iter().find(|project| { + project.project_id.as_ref() == Some(&update.project_id) + && project.version_id.as_ref() == Some(&update.update_version_id) + && project.relative_path != update.relative_path + && !duplicate_paths.contains(&project.relative_path) + && updateable_paths.contains(&project.relative_path) + }) else { + return; + }; + let selected_enabled = installed.iter().any(|project| { + project.relative_path == update.relative_path && project.enabled + }); + + if current.enabled && !selected_enabled { + duplicate_paths.push(std::mem::replace( + &mut update.relative_path, + current.relative_path.clone(), + )); + update + .current_version_id + .clone_from(&update.update_version_id); + } else { + duplicate_paths.push(current.relative_path.clone()); + } +} + async fn bulk_updateable_project_paths( instance_id: &str, shared_instance_member: bool, diff --git a/packages/app-lib/src/state/instances/commands/content_mutation.rs b/packages/app-lib/src/state/instances/commands/content_mutation.rs index 72324cdcc1..fa5e977d94 100644 --- a/packages/app-lib/src/state/instances/commands/content_mutation.rs +++ b/packages/app-lib/src/state/instances/commands/content_mutation.rs @@ -233,7 +233,9 @@ impl<'a> InstanceContent<'a> { )); } let enabled = if request.previous_path.is_some() { - existing.as_ref().is_some_and(|file| file.enabled) + request.enabled_override.unwrap_or_else(|| { + existing.as_ref().is_some_and(|file| file.enabled) + }) } else { request.enabled_override.unwrap_or_else(|| { !request.requested_path.ends_with(".disabled")