diff --git a/src/pages/AiProvidersAmpcodeEditPage.tsx b/src/pages/AiProvidersAmpcodeEditPage.tsx index ebc9f7c..c2a9dfb 100644 --- a/src/pages/AiProvidersAmpcodeEditPage.tsx +++ b/src/pages/AiProvidersAmpcodeEditPage.tsx @@ -13,6 +13,7 @@ import { ampcodeApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { AmpcodeConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; +import { areStringArraysEqual } from '@/utils/compare'; import { buildAmpcodeFormState, entriesToAmpcodeMappings, @@ -38,20 +39,52 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }> return acc; }, []); -const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) => - entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({ - upstreamApiKey: entry.upstreamApiKey, - apiKeys: entry.apiKeys, - })); +type AmpcodeFormBaseline = { + upstreamUrl: string; + upstreamApiKey: string; + forceModelMappings: boolean; + upstreamApiKeys: ReturnType; + modelMappings: ReturnType; +}; -const buildAmpcodeSignature = (form: AmpcodeFormState) => - JSON.stringify({ - upstreamUrl: String(form.upstreamUrl ?? '').trim(), - upstreamApiKey: String(form.upstreamApiKey ?? '').trim(), - forceModelMappings: Boolean(form.forceModelMappings), - upstreamApiKeys: normalizeUpstreamApiKeyEntries(form), - modelMappings: normalizeMappingEntries(form.mappingEntries), - }); +const buildAmpcodeBaseline = (form: AmpcodeFormState): AmpcodeFormBaseline => ({ + upstreamUrl: String(form.upstreamUrl ?? '').trim(), + upstreamApiKey: String(form.upstreamApiKey ?? '').trim(), + forceModelMappings: Boolean(form.forceModelMappings), + upstreamApiKeys: entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries), + modelMappings: normalizeMappingEntries(form.mappingEntries), +}); + +const areUpstreamApiKeysEqual = ( + a: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[], + b: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[] +) => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!left || !right) return false; + if (left.upstreamApiKey !== right.upstreamApiKey) return false; + if (!areStringArraysEqual(left.apiKeys, right.apiKeys)) return false; + } + return true; +}; + +const areModelMappingsEqual = ( + a: readonly { from: string; to: string }[], + b: readonly { from: string; to: string }[] +) => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!left || !right) return false; + if (left.from !== right.from || left.to !== right.to) return false; + } + return true; +}; export function AiProvidersAmpcodeEditPage() { const { t } = useTranslation(); @@ -72,9 +105,7 @@ export function AiProvidersAmpcodeEditPage() { const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); - const [baselineSignature, setBaselineSignature] = useState(() => - buildAmpcodeSignature(buildAmpcodeFormState(null)) - ); + const [baseline, setBaseline] = useState(() => buildAmpcodeBaseline(buildAmpcodeFormState(null))); const initializedRef = useRef(false); const mountedRef = useRef(false); @@ -119,7 +150,7 @@ export function AiProvidersAmpcodeEditPage() { setError(''); const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null); setForm(initialForm); - setBaselineSignature(buildAmpcodeSignature(initialForm)); + setBaseline(buildAmpcodeBaseline(initialForm)); void (async () => { try { @@ -131,7 +162,7 @@ export function AiProvidersAmpcodeEditPage() { clearCache('ampcode'); const nextForm = buildAmpcodeFormState(ampcode); setForm(nextForm); - setBaselineSignature(buildAmpcodeSignature(nextForm)); + setBaseline(buildAmpcodeBaseline(nextForm)); } catch (err: unknown) { if (!mountedRef.current) return; setError(getErrorMessage(err) || t('notification.refresh_failed')); @@ -143,8 +174,28 @@ export function AiProvidersAmpcodeEditPage() { })(); }, [clearCache, t, updateConfigValue]); - const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]); - const isDirty = baselineSignature !== currentSignature; + const normalizedUpstreamApiKeys = useMemo( + () => entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries), + [form.upstreamApiKeyEntries] + ); + const normalizedModelMappings = useMemo( + () => normalizeMappingEntries(form.mappingEntries), + [form.mappingEntries] + ); + const isUpstreamApiKeysDirty = useMemo( + () => !areUpstreamApiKeysEqual(baseline.upstreamApiKeys, normalizedUpstreamApiKeys), + [baseline.upstreamApiKeys, normalizedUpstreamApiKeys] + ); + const isModelMappingsDirtyNormalized = useMemo( + () => !areModelMappingsEqual(baseline.modelMappings, normalizedModelMappings), + [baseline.modelMappings, normalizedModelMappings] + ); + const isDirty = + baseline.upstreamUrl !== form.upstreamUrl.trim() || + baseline.upstreamApiKey !== form.upstreamApiKey.trim() || + baseline.forceModelMappings !== Boolean(form.forceModelMappings) || + isUpstreamApiKeysDirty || + isModelMappingsDirtyNormalized; const canGuard = !loading && !saving; const { allowNextNavigation } = useUnsavedChangesGuard({ @@ -263,7 +314,7 @@ export function AiProvidersAmpcodeEditPage() { clearCache('ampcode'); showNotification(t('notification.ampcode_updated'), 'success'); allowNextNavigation(); - setBaselineSignature(buildAmpcodeSignature(form)); + setBaseline(buildAmpcodeBaseline(form)); handleBack(); } catch (err: unknown) { const message = getErrorMessage(err); diff --git a/src/pages/AiProvidersCodexEditPage.tsx b/src/pages/AiProvidersCodexEditPage.tsx index 0afad17..98a37d8 100644 --- a/src/pages/AiProvidersCodexEditPage.tsx +++ b/src/pages/AiProvidersCodexEditPage.tsx @@ -16,6 +16,7 @@ import { modelsApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { ProviderKeyConfig } from '@/types'; import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers'; +import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare'; import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils'; import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; import type { ProviderFormState } from '@/components/providers'; @@ -63,21 +64,30 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) return acc; }, []); -const buildCodexSignature = (form: ProviderFormState) => - JSON.stringify({ - apiKey: String(form.apiKey ?? '').trim(), - priority: - form.priority !== undefined && Number.isFinite(form.priority) - ? Math.trunc(form.priority) - : null, - prefix: String(form.prefix ?? '').trim(), - baseUrl: String(form.baseUrl ?? '').trim(), - websockets: Boolean(form.websockets), - proxyUrl: String(form.proxyUrl ?? '').trim(), - headers: normalizeHeaderEntries(form.headers), - models: normalizeModelEntries(form.modelEntries), - excludedModels: parseExcludedModels(form.excludedText ?? ''), - }); +type CodexFormBaseline = { + apiKey: string; + priority: number | null; + prefix: string; + baseUrl: string; + websockets: boolean; + proxyUrl: string; + headers: ReturnType; + models: ReturnType; + excludedModels: string[]; +}; + +const buildCodexBaseline = (form: ProviderFormState): CodexFormBaseline => ({ + apiKey: String(form.apiKey ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + websockets: Boolean(form.websockets), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeModelEntries(form.modelEntries), + excludedModels: parseExcludedModels(form.excludedText ?? ''), +}); export function AiProvidersCodexEditPage() { const { t } = useTranslation(); @@ -98,9 +108,7 @@ export function AiProvidersCodexEditPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState(() => buildEmptyForm()); - const [baselineSignature, setBaselineSignature] = useState(() => - buildCodexSignature(buildEmptyForm()) - ); + const [baseline, setBaseline] = useState(() => buildCodexBaseline(buildEmptyForm())); const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false); const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState(''); @@ -186,16 +194,50 @@ export function AiProvidersCodexEditPage() { excludedText: excludedModelsToText(initialData.excludedModels), }; setForm(nextForm); - setBaselineSignature(buildCodexSignature(nextForm)); + setBaseline(buildCodexBaseline(nextForm)); return; } const nextForm = buildEmptyForm(); setForm(nextForm); - setBaselineSignature(buildCodexSignature(nextForm)); + setBaseline(buildCodexBaseline(nextForm)); }, [initialData, loading]); - const currentSignature = useMemo(() => buildCodexSignature(form), [form]); - const isDirty = baselineSignature !== currentSignature; + const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]); + const normalizedModels = useMemo( + () => normalizeModelEntries(form.modelEntries), + [form.modelEntries] + ); + const normalizedExcludedModels = useMemo( + () => parseExcludedModels(form.excludedText ?? ''), + [form.excludedText] + ); + const normalizedPriority = useMemo(() => { + return form.priority !== undefined && Number.isFinite(form.priority) + ? Math.trunc(form.priority) + : null; + }, [form.priority]); + const isHeadersDirty = useMemo( + () => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders), + [baseline.headers, normalizedHeaders] + ); + const isModelsDirty = useMemo( + () => !areModelEntriesEqual(baseline.models, normalizedModels), + [baseline.models, normalizedModels] + ); + const isExcludedModelsDirty = useMemo( + () => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels), + [baseline.excludedModels, normalizedExcludedModels] + ); + const isDirty = + baseline.apiKey !== form.apiKey.trim() || + baseline.priority !== normalizedPriority || + baseline.prefix !== String(form.prefix ?? '').trim() || + baseline.baseUrl !== String(form.baseUrl ?? '').trim() || + baseline.websockets !== Boolean(form.websockets) || + baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() || + isHeadersDirty || + isModelsDirty || + isExcludedModelsDirty; const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex; const { allowNextNavigation } = useUnsavedChangesGuard({ @@ -430,7 +472,7 @@ export function AiProvidersCodexEditPage() { 'success' ); allowNextNavigation(); - setBaselineSignature(buildCodexSignature(form)); + setBaseline(buildCodexBaseline(form)); handleBack(); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; diff --git a/src/pages/AiProvidersGeminiEditPage.tsx b/src/pages/AiProvidersGeminiEditPage.tsx index 3c485ef..8dd3ce3 100644 --- a/src/pages/AiProvidersGeminiEditPage.tsx +++ b/src/pages/AiProvidersGeminiEditPage.tsx @@ -15,6 +15,7 @@ import { modelsApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { GeminiKeyConfig } from '@/types'; import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers'; +import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare'; import type { ModelInfo } from '@/utils/models'; import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils'; import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; @@ -60,20 +61,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) return acc; }, []); -const buildGeminiSignature = (form: GeminiFormState) => - JSON.stringify({ - apiKey: String(form.apiKey ?? '').trim(), - priority: - form.priority !== undefined && Number.isFinite(form.priority) - ? Math.trunc(form.priority) - : null, - prefix: String(form.prefix ?? '').trim(), - baseUrl: String(form.baseUrl ?? '').trim(), - proxyUrl: String(form.proxyUrl ?? '').trim(), - headers: normalizeHeaderEntries(form.headers), - models: normalizeModelEntries(form.modelEntries), - excludedModels: parseExcludedModels(form.excludedText ?? ''), - }); +type GeminiFormBaseline = { + apiKey: string; + priority: number | null; + prefix: string; + baseUrl: string; + proxyUrl: string; + headers: ReturnType; + models: ReturnType; + excludedModels: string[]; +}; + +const buildGeminiBaseline = (form: GeminiFormState): GeminiFormBaseline => ({ + apiKey: String(form.apiKey ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeModelEntries(form.modelEntries), + excludedModels: parseExcludedModels(form.excludedText ?? ''), +}); export function AiProvidersGeminiEditPage() { const { t } = useTranslation(); @@ -94,9 +103,7 @@ export function AiProvidersGeminiEditPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState(() => buildEmptyForm()); - const [baselineSignature, setBaselineSignature] = useState(() => - buildGeminiSignature(buildEmptyForm()) - ); + const [baseline, setBaseline] = useState(() => buildGeminiBaseline(buildEmptyForm())); const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false); const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState(''); @@ -185,12 +192,12 @@ export function AiProvidersGeminiEditPage() { excludedText: excludedModelsToText(initialData.excludedModels), }; setForm(nextForm); - setBaselineSignature(buildGeminiSignature(nextForm)); + setBaseline(buildGeminiBaseline(nextForm)); return; } const nextForm = buildEmptyForm(); setForm(nextForm); - setBaselineSignature(buildGeminiSignature(nextForm)); + setBaseline(buildGeminiBaseline(nextForm)); }, [initialData, loading]); const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; @@ -378,8 +385,41 @@ export function AiProvidersGeminiEditPage() { setModelDiscoveryOpen(false); }; - const currentSignature = useMemo(() => buildGeminiSignature(form), [form]); - const isDirty = baselineSignature !== currentSignature; + const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]); + const normalizedModels = useMemo( + () => normalizeModelEntries(form.modelEntries), + [form.modelEntries] + ); + const normalizedExcludedModels = useMemo( + () => parseExcludedModels(form.excludedText ?? ''), + [form.excludedText] + ); + const normalizedPriority = useMemo(() => { + return form.priority !== undefined && Number.isFinite(form.priority) + ? Math.trunc(form.priority) + : null; + }, [form.priority]); + const isHeadersDirty = useMemo( + () => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders), + [baseline.headers, normalizedHeaders] + ); + const isModelsDirty = useMemo( + () => !areModelEntriesEqual(baseline.models, normalizedModels), + [baseline.models, normalizedModels] + ); + const isExcludedModelsDirty = useMemo( + () => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels), + [baseline.excludedModels, normalizedExcludedModels] + ); + const isDirty = + baseline.apiKey !== form.apiKey.trim() || + baseline.priority !== normalizedPriority || + baseline.prefix !== String(form.prefix ?? '').trim() || + baseline.baseUrl !== String(form.baseUrl ?? '').trim() || + baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() || + isHeadersDirty || + isModelsDirty || + isExcludedModelsDirty; const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex; const { allowNextNavigation } = useUnsavedChangesGuard({ @@ -432,7 +472,7 @@ export function AiProvidersGeminiEditPage() { 'success' ); allowNextNavigation(); - setBaselineSignature(buildGeminiSignature(form)); + setBaseline(buildGeminiBaseline(form)); handleBack(); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; diff --git a/src/pages/AiProvidersVertexEditPage.tsx b/src/pages/AiProvidersVertexEditPage.tsx index 06405fa..be0c3c1 100644 --- a/src/pages/AiProvidersVertexEditPage.tsx +++ b/src/pages/AiProvidersVertexEditPage.tsx @@ -15,6 +15,7 @@ import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { ProviderKeyConfig } from '@/types'; import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers'; +import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare'; import type { VertexFormState } from '@/components/providers'; import layoutStyles from './AiProvidersEditLayout.module.scss'; @@ -47,18 +48,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) return acc; }, []); -const buildVertexSignature = (form: VertexFormState) => - JSON.stringify({ - apiKey: String(form.apiKey ?? '').trim(), - priority: - form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, - prefix: String(form.prefix ?? '').trim(), - baseUrl: String(form.baseUrl ?? '').trim(), - proxyUrl: String(form.proxyUrl ?? '').trim(), - headers: normalizeHeaderEntries(form.headers), - models: normalizeModelEntries(form.modelEntries), - excludedModels: parseExcludedModels(form.excludedText ?? ''), - }); +type VertexFormBaseline = { + apiKey: string; + priority: number | null; + prefix: string; + baseUrl: string; + proxyUrl: string; + headers: ReturnType; + models: ReturnType; + excludedModels: string[]; +}; + +const buildVertexBaseline = (form: VertexFormState): VertexFormBaseline => ({ + apiKey: String(form.apiKey ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeModelEntries(form.modelEntries), + excludedModels: parseExcludedModels(form.excludedText ?? ''), +}); export function AiProvidersVertexEditPage() { const { t } = useTranslation(); @@ -79,7 +90,7 @@ export function AiProvidersVertexEditPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState(() => buildEmptyForm()); - const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm())); + const [baseline, setBaseline] = useState(() => buildVertexBaseline(buildEmptyForm())); const hasIndexParam = typeof params.index === 'string'; const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]); @@ -160,18 +171,51 @@ export function AiProvidersVertexEditPage() { excludedText: excludedModelsToText(initialData.excludedModels), }; setForm(nextForm); - setBaselineSignature(buildVertexSignature(nextForm)); + setBaseline(buildVertexBaseline(nextForm)); return; } const nextForm = buildEmptyForm(); setForm(nextForm); - setBaselineSignature(buildVertexSignature(nextForm)); + setBaseline(buildVertexBaseline(nextForm)); }, [initialData, loading]); const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; - const currentSignature = useMemo(() => buildVertexSignature(form), [form]); - const isDirty = baselineSignature !== currentSignature; + const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]); + const normalizedModels = useMemo( + () => normalizeModelEntries(form.modelEntries), + [form.modelEntries] + ); + const normalizedExcludedModels = useMemo( + () => parseExcludedModels(form.excludedText ?? ''), + [form.excludedText] + ); + const normalizedPriority = useMemo(() => { + return form.priority !== undefined && Number.isFinite(form.priority) + ? Math.trunc(form.priority) + : null; + }, [form.priority]); + const isHeadersDirty = useMemo( + () => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders), + [baseline.headers, normalizedHeaders] + ); + const isModelsDirty = useMemo( + () => !areModelEntriesEqual(baseline.models, normalizedModels), + [baseline.models, normalizedModels] + ); + const isExcludedModelsDirty = useMemo( + () => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels), + [baseline.excludedModels, normalizedExcludedModels] + ); + const isDirty = + baseline.apiKey !== form.apiKey.trim() || + baseline.priority !== normalizedPriority || + baseline.prefix !== String(form.prefix ?? '').trim() || + baseline.baseUrl !== String(form.baseUrl ?? '').trim() || + baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() || + isHeadersDirty || + isModelsDirty || + isExcludedModelsDirty; const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex; const { allowNextNavigation } = useUnsavedChangesGuard({ @@ -230,7 +274,7 @@ export function AiProvidersVertexEditPage() { 'success' ); allowNextNavigation(); - setBaselineSignature(buildVertexSignature(form)); + setBaseline(buildVertexBaseline(form)); handleBack(); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; diff --git a/src/utils/compare.ts b/src/utils/compare.ts new file mode 100644 index 0000000..6b3f0b7 --- /dev/null +++ b/src/utils/compare.ts @@ -0,0 +1,39 @@ +export function areStringArraysEqual(a: readonly string[], b: readonly string[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function areKeyValueEntriesEqual( + a: readonly { key: string; value: string }[], + b: readonly { key: string; value: string }[] +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!left || !right) return false; + if (left.key !== right.key || left.value !== right.value) return false; + } + return true; +} + +export function areModelEntriesEqual( + a: readonly { name: string; alias: string }[], + b: readonly { name: string; alias: string }[] +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!left || !right) return false; + if (left.name !== right.name || left.alias !== right.alias) return false; + } + return true; +} +