From 91f483b960de621d4d25912598327840472bbd72 Mon Sep 17 00:00:00 2001 From: Disy Date: Thu, 24 Sep 2026 23:44:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=8D=B3=E6=97=B6=E5=88=87=E6=8D=A2=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=E8=AD=A6=E5=91=8A=E6=94=B9=E4=B8=BA=E4=BB=85=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composables/content-filtering.ts | 1 + .../composables/content-pipeline.test.ts | 136 ++++++++++++++++++ .../composables/content-pipeline.ts | 43 +++--- .../src/layouts/shared/content-tab/layout.vue | 1 + packages/ui/src/locales/en-US/index.json | 6 + packages/ui/src/locales/zh-CN/index.json | 6 + packages/ui/src/locales/zh-TW/index.json | 6 + 7 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts index 028b818..4b6ec08 100644 --- a/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts +++ b/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts @@ -38,4 +38,5 @@ export function canToggleContentItem(item: ContentItem): boolean { export interface ContentFilterOption { id: string label: string + tooltip?: string } diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts new file mode 100644 index 0000000..29fb59d --- /dev/null +++ b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import test, { type TestContext } from 'node:test' + +import { effectScope, nextTick, ref } from 'vue' + +import type { ContentItem } from '../types.ts' + +// Keep the real Vue state, search, and filter pipeline; replace only the UI's +// translation provider, which normally requires an application context. +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '#ui/composables/i18n' || specifier === '../composables/i18n') { + return { + url: `data:text/javascript,${encodeURIComponent('export const defineMessage=x=>x, defineMessages=x=>x, useVIntl=()=>({formatMessage:x=>x.defaultMessage})')}`, + shortCircuit: true, + } + } + if (specifier.startsWith('#ui/')) { + return nextResolve( + new URL(`../../../../${specifier.slice(4)}.ts`, import.meta.url).href, + context, + ) + } + if (specifier.startsWith('./') && !specifier.endsWith('.ts')) { + return nextResolve(`${specifier}.ts`, context) + } + return nextResolve(specifier, context) + }, +}) +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') +Object.defineProperty(globalThis, 'window', { configurable: true, value: {} }) +const { useContentPipeline } = await import('./content-pipeline.ts') +hooks.deregister() +if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) +else Reflect.deleteProperty(globalThis, 'window') + +function item(id: string, overrides: Partial = {}): ContentItem { + return { + id, + project: { id, title: id, slug: id, icon_url: null }, + file_name: `${id}.jar`, + project_type: 'mod', + update: null, + origin_provider: null, + provider_refs: [], + enabled: true, + ...overrides, + } +} + +function fixture(t: TestContext) { + t.mock.timers.enable({ apis: ['setTimeout'] }) + const update = { + provider: 'modrinth' as const, + project_id: 'project', + current_version_id: 'v1', + target_version_id: 'v2', + } + const items = ref([ + item('ordinary'), + item('update', { update }), + item('warning', { environment: 'client_only' }), + item('both', { update, pack_client_depends: true }), + item('disabled', { enabled: false }), + ]) + const scope = effectScope() + t.after(() => scope.stop()) + const pipeline = scope.run(() => + useContentPipeline({ + items, + duplicateItems: ref([]), + sortItems: (items) => items, + getItemId: (item) => item.id, + showUpdateFilter: true, + showWarningsFilter: true, + }), + )! + return { + ...pipeline, + ids: () => pipeline.filteredItems.value.map((item) => item.id), + async flush() { + await nextTick() + t.mock.timers.tick(100) + await nextTick() + }, + } +} + +test('status filters update the visible list on every click without reloading items', async (t) => { + const p = fixture(t) + await p.flush() + assert.equal(p.ids().length, 5) + p.toggleStatusFilter('updates') + await p.flush() + assert.deepEqual(p.ids(), ['update', 'both']) + p.toggleStatusFilter('warnings') + await p.flush() + assert.deepEqual(p.ids(), ['both']) + p.toggleStatusFilter('updates') + await p.flush() + assert.deepEqual(p.ids(), ['warning', 'both']) + p.toggleStatusFilter('warnings') + await p.flush() + assert.equal(p.ids().length, 5) +}) + +test('All clears status filters and restores the visible list without a refresh', async (t) => { + const p = fixture(t) + await p.flush() + p.toggleStatusFilter('warnings') + await p.flush() + assert.deepEqual(p.ids(), ['warning', 'both']) + p.selectedStatusFilters.value = [] + await p.flush() + assert.equal(p.ids().length, 5) +}) + +test('enabled and disabled remain exclusive and can each be deselected', async (t) => { + const p = fixture(t) + await p.flush() + p.toggleStatusFilter('enabled') + await p.flush() + assert.deepEqual(p.ids(), ['ordinary', 'update', 'warning', 'both']) + p.toggleStatusFilter('enabled') + await p.flush() + assert.equal(p.ids().length, 5) + p.toggleStatusFilter('enabled') + p.toggleStatusFilter('disabled') + await p.flush() + assert.deepEqual(p.selectedStatusFilters.value, ['disabled']) + assert.deepEqual(p.ids(), ['disabled']) + p.toggleStatusFilter('disabled') + await p.flush() + assert.equal(p.ids().length, 5) +}) diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts index 8cee041..dac72c5 100644 --- a/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts +++ b/packages/ui/src/layouts/shared/content-tab/composables/content-pipeline.ts @@ -62,9 +62,14 @@ const filterMessages = defineMessages({ id: 'content.filter.updates', defaultMessage: 'Update available', }, - warnings: { - id: 'content.filter.warnings', - defaultMessage: 'Warnings', + clientOnly: { + id: 'content.filter.client-only', + defaultMessage: 'Client only', + }, + clientOnlyDescription: { + id: 'content.filter.client-only-description', + defaultMessage: + 'Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers.', }, duplicates: { id: 'content.filter.duplicates', @@ -329,7 +334,11 @@ export function useContentPipeline(config: ContentPipelineConfig) { row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) }) } if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) { - row2.push({ id: 'warnings', label: formatMessage(filterMessages.warnings) }) + row2.push({ + id: 'warnings', + label: formatMessage(filterMessages.clientOnly), + tooltip: formatMessage(filterMessages.clientOnlyDescription), + }) } if (hasEnabled && hasDisabled) { @@ -472,26 +481,20 @@ export function useContentPipeline(config: ContentPipelineConfig) { } function toggleStatusFilter(filterId: string) { - if (filterId === 'enabled' || filterId === 'disabled') { - const index = selectedStatusFilters.value.indexOf(filterId) - const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled' - if (index === -1) { - selectedStatusFilters.value = [ - ...selectedStatusFilters.value.filter((f) => f !== otherStatusFilter), - filterId, - ] - } else { - selectedStatusFilters.value.splice(index, 1) - } + const filters = selectedStatusFilters.value + // Replace the array so both the pipeline and saved-filter watchers are notified. + if (filters.includes(filterId)) { + selectedStatusFilters.value = filters.filter((id) => id !== filterId) return } - const index = selectedStatusFilters.value.indexOf(filterId) - if (index === -1) { - selectedStatusFilters.value.push(filterId) - } else { - selectedStatusFilters.value.splice(index, 1) + if (filterId === 'enabled' || filterId === 'disabled') { + const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled' + selectedStatusFilters.value = [...filters.filter((id) => id !== otherStatusFilter), filterId] + return } + + selectedStatusFilters.value = [...filters, filterId] } return { diff --git a/packages/ui/src/layouts/shared/content-tab/layout.vue b/packages/ui/src/layouts/shared/content-tab/layout.vue index 28c0c3c..8fd3e38 100644 --- a/packages/ui/src/layouts/shared/content-tab/layout.vue +++ b/packages/ui/src/layouts/shared/content-tab/layout.vue @@ -1110,6 +1110,7 @@ const confirmUnlinkModal = ref>() - + diff --git a/apps/app-frontend/src/components/ui/AppActionBar.vue b/apps/app-frontend/src/components/ui/AppActionBar.vue index 9b17eef..392b7cc 100644 --- a/apps/app-frontend/src/components/ui/AppActionBar.vue +++ b/apps/app-frontend/src/components/ui/AppActionBar.vue @@ -137,6 +137,11 @@ {{ process.instance.name }} + {{ + formatMessage(messages.windowStarted, { + time: new Date(process.start_time), + }) + }} @@ -307,6 +312,7 @@ const notificationCenterShown = ref(false) interface RunningProcess { uuid: string + start_time: string instance_id: string instance: GameInstance } @@ -320,6 +326,10 @@ interface LoadingEventPayload { } const messages = defineMessages({ + windowStarted: { + id: 'app.action-bar.window-started', + defaultMessage: 'Started {time, time, medium}', + }, offline: { id: 'app.action-bar.offline', defaultMessage: 'Offline', @@ -346,7 +356,7 @@ const messages = defineMessages({ }, stopInstance: { id: 'app.action-bar.stop-instance', - defaultMessage: 'Stop instance', + defaultMessage: 'Stop window', }, viewLogs: { id: 'app.action-bar.view-logs', @@ -401,8 +411,9 @@ const refresh = async () => { const processes = ((await getRunningProcesses().catch((error) => { handleError(error) return [] - })) ?? []) as Array<{ uuid: string; instance_id: string }> - const instanceIds = processes.map((process) => process.instance_id) + })) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }> + processes.sort((a, b) => a.start_time.localeCompare(b.start_time)) + const instanceIds = [...new Set(processes.map((process) => process.instance_id))] const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => { handleError(error) return [] @@ -420,9 +431,9 @@ const refresh = async () => { } }) .filter((process): process is RunningProcess => process !== null) - if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) { - selectedProcess.value = currentProcesses.value[0] - } + selectedProcess.value = + currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ?? + currentProcesses.value[0] } await refresh() diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue index 56b5ff3..893ca10 100644 --- a/apps/app-frontend/src/components/ui/Instance.vue +++ b/apps/app-frontend/src/components/ui/Instance.vue @@ -1,12 +1,5 @@