diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9626de6..77ada3a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,7 +41,7 @@ import { } from "../shared/provider-daily-limits"; import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order"; import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection"; -import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runAccountEnableRefresh, runOptimisticAccountUpdate, sortAccountServices, updateAccountRowSelection } from "./account-ui"; +import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; @@ -324,6 +324,15 @@ interface AccountContextMenuState { rowId: string; } +function getAccountToggleTarget(account: AccountTableRow): AccountToggleTarget { + if (account.toggleKind === "rd" && account.accountId) return { type: "realdebrid", accountId: account.accountId }; + if (account.toggleKind === "mega" && account.accountId) { + return { type: "megadebrid", provider: account.entry.kind as "megadebrid-api" | "megadebrid-web", accountId: account.accountId }; + } + if (account.toggleKind === "dl" && account.dlKey) return { type: "debridlink", accountId: account.dlKey.id }; + return { type: "provider", provider: account.entry.provider }; +} + type SettingsThemeChoice = AppTheme | "system"; interface RendererSettingsDraft extends RendererSettings { @@ -366,25 +375,6 @@ export function resolveSettingsSaveCompletion(revisionAtStart: number, currentRe : { saveState: "dirty", applyPersistedTheme: false, toast: "Zwischenstand gespeichert – weitere Änderungen sind ungespeichert" }; } -function settingsValueEqual(left: unknown, right: unknown): boolean { - return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); -} - -export function mergeConcurrentSpecificSettings( - base: RendererSettingsDraft, - requested: RendererSettingsDraft, - persisted: RendererSettings, - current: RendererSettingsDraft -): RendererSettingsDraft { - const merged = { ...current } as Record; - for (const key of Object.keys(requested) as Array) { - if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key]) && key in persisted) { - merged[key] = persisted[key as keyof RendererSettings]; - } - } - return merged as unknown as RendererSettingsDraft; -} - export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme { return choice === "system" ? (prefersLight ? "light" : "dark") : choice; } @@ -1812,6 +1802,10 @@ export function App(): ReactElement { const [accountEditSecretBusy, setAccountEditSecretBusy] = useState(null); const [accountDialogSearch, setAccountDialogSearch] = useState(""); const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all"); + const [pendingAccountToggles, setPendingAccountToggles] = useState>({}); + const accountToggleQueueRef = useRef(createAccountToggleQueue()); + const accountToggleSequenceRef = useRef(0); + const settingsMutationSequenceRef = useRef(0); const [keyStatsPopup, setKeyStatsPopup] = useState(null); const [debridLinkHostLimits, setDebridLinkHostLimits] = useState>({}); const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false); @@ -2718,8 +2712,21 @@ export function App(): ReactElement { }); } } - return rows; - }, [configuredAccounts, settingsDraft, snapshot.accounts]); + return rows.map((row) => { + const pending = pendingAccountToggles[row.rowKey]; + if (!pending) return row; + const disabled = !pending.enabled; + return { + ...row, + disabled, + entry: { + ...row.entry, + disabled, + statusLabel: disabled ? "Deaktiviert" : "Aktiviert" + } + }; + }); + }, [configuredAccounts, pendingAccountToggles, settingsDraft, snapshot.accounts]); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); @@ -2988,36 +2995,6 @@ export function App(): ReactElement { })); }; - const persistSpecificSettings = async ( - nextDraft: RendererSettingsDraft, - persistenceContext = { - revisionAtStart: settingsDraftRevisionRef.current, - draftAtStart: settingsDraft, - themeChoiceAtStart: settingsThemeChoiceRef.current - } - ): Promise => { - const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext; - const normalizedDraft = { - ...nextDraft, - ...normalizeProviderSelectionForSettings(nextDraft) - }; - const update: RendererSettingsUpdate = { ...normalizedDraft }; - if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; - if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; - const result = await window.rd.updateSettings(update); - persistedSettingsRef.current = result; - persistedThemeChoiceRef.current = themeChoiceAtStart; - if (settingsDraftRevisionRef.current === revisionAtStart) { - applyPersistedSettings(result, true, themeChoiceAtStart); - } else { - setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current)); - settingsDirtyRef.current = true; - setSettingsDirty(true); - setSettingsSaveState("dirty"); - } - return result; - }; - const runAccountQuickAction = async (action: AccountQuickAction, accountId?: string | null): Promise => { switch (action) { case "realdebrid-login": @@ -3122,16 +3099,18 @@ export function App(): ReactElement { return; } await performQuickAction(async () => { - await persistDraftSettings(); - const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot)); - setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); - applyPersistedSettings(result.settings); - closeAccountEditDialog(); - if (quickAction) { - await runAccountQuickAction(quickAction, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null); - } else { - showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200); - } + await runQueuedSettingsMutation(async () => { + await persistDraftSettingsDirect(); + const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot)); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); + closeAccountEditDialog(); + if (quickAction) { + await runAccountQuickAction(quickAction, editSnapshot.target.type === "single" ? editSnapshot.target.accountId : null); + } else { + showToast(`${findAccountOption(editSnapshot.target.kind).title} gespeichert`, 2200); + } + }); }, (error) => { showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); }); @@ -3150,32 +3129,34 @@ export function App(): ReactElement { if (validationError) { showToast(validationError, 2800); return; - } + } const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { - await persistDraftSettings(); - if (dialogSnapshot.kind === "realdebrid-web") { - const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`; - const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId); - if (!request) throw new Error("Account-Payload ist ungültig"); - await window.rd.openRealDebridLogin(request); + await runQueuedSettingsMutation(async () => { + await persistDraftSettingsDirect(); + if (dialogSnapshot.kind === "realdebrid-web") { + const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`; + const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId); + if (!request) throw new Error("Account-Payload ist ungültig"); + await window.rd.openRealDebridLogin(request); + closeAccountDialog(); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); + return; + } + const command = buildAccountCreateCommand(dialogSnapshot); + if (!command) throw new Error("Account-Payload ist ungültig"); + const result = await window.rd.createAccount(command); + const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings)); + setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts })); + applyPersistedSettings(persistedSettings); closeAccountDialog(); - showToast("Real-Debrid Login-Fenster geöffnet", 2200); - return; - } - const command = buildAccountCreateCommand(dialogSnapshot); - if (!command) throw new Error("Account-Payload ist ungültig"); - const result = await window.rd.createAccount(command); - const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings)); - setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts })); - applyPersistedSettings(persistedSettings); - closeAccountDialog(); - if (quickAction) { - await runAccountQuickAction(quickAction, result.accountId); - } else if (selectedOption) { - showToast(`${selectedOption.title} gespeichert`, 2200); - } - void checkAccounts("active"); + if (quickAction) { + await runAccountQuickAction(quickAction, result.accountId); + } else if (selectedOption) { + showToast(`${selectedOption.title} gespeichert`, 2200); + } + void checkAccounts("active"); + }); }, (error) => { showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); }); @@ -3201,70 +3182,55 @@ export function App(): ReactElement { }); }; - const persistAccountToggle = async ( - nextDraft: RendererSettingsDraft, - refreshBeforePersist?: () => ReturnType - ): Promise => { - const previousDraft = settingsDraft; - const previousDirty = settingsDirtyRef.current; - const previousSaveState = settingsSaveState; - const themeChoiceAtStart = settingsThemeChoiceRef.current; - const revision = ++settingsDraftRevisionRef.current; - const persistenceContext = { - revisionAtStart: revision, - draftAtStart: previousDraft, - themeChoiceAtStart - }; - return runOptimisticAccountUpdate( - () => { - settingsDirtyRef.current = true; - setSettingsDirty(true); - setSettingsSaveState("saving"); - setSettingsDraft(nextDraft); - }, - () => runAccountEnableRefresh( - refreshBeforePersist, - () => persistSpecificSettings(nextDraft, persistenceContext) - ), - () => { - if (settingsDraftRevisionRef.current !== revision) return; - settingsDraftRevisionRef.current += 1; - settingsDirtyRef.current = previousDirty; - setSettingsDirty(previousDirty); - setSettingsSaveState(previousSaveState); - setSettingsDraft(previousDraft); + const requestAccountToggle = (row: AccountTableRow, enabled: boolean): void => { + const sequence = ++accountToggleSequenceRef.current; + const subject = row.toggleKind === "dl" && row.dlKey + ? `${row.entry.serviceLabel} ${row.dlKey.label}` + : row.entry.serviceLabel; + const check = enabled + ? row.toggleKind === "rd" && row.accountId + ? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "realdebrid-api" | "realdebrid-web", accountId: row.accountId || undefined }) + : row.toggleKind === "mega" && row.accountId + ? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "megadebrid-api" | "megadebrid-web", accountId: row.accountId || undefined }) + : row.toggleKind === "dl" && row.dlKey + ? () => window.rd.checkAccountCredentials({ kind: "debridlink-api", accountId: row.dlKey?.id }) + : row.entry.kind === "deepbrid-api" + ? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" }) + : undefined + : undefined; + setPendingAccountToggles((current) => ({ + ...current, + [row.rowKey]: { enabled, sequence } + })); + void enqueueAccountToggleIntent(accountToggleQueueRef.current, { + key: row.rowKey, + target: getAccountToggleTarget(row), + enabled, + check + }, { + getSettings: () => persistedSettingsRef.current, + persist: async (patch) => { + const result = await window.rd.updateSettings(patch); + persistedSettingsRef.current = result; + setSnapshot((current) => ({ ...current, settings: result })); + setSettingsDraft((current) => mergeAccountToggleSettings(current, result)); + return result; } - ); + }).then((result) => { + if (result.status === "superseded") return; + setPendingAccountToggles((current) => { + if (current[row.rowKey]?.sequence !== sequence) return current; + const next = { ...current }; + delete next[row.rowKey]; + return next; + }); + if (result.status === "failed") { + showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200); + return; + } + showToast(`${subject} ${enabled ? "aktiviert" : "deaktiviert"}`, 2200); + }); }; - - const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry, enabled: boolean): Promise => { - await performQuickAction(async () => { - const nextState = buildScopedAccountEnabledState( - settingsDraft.disabledProviders || [], - ["debridlink"], - settingsDraft.debridLinkDisabledKeyIds || [], - key.id, - enabled - ); - const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - disabledProviders: nextState.disabledProviders, - debridLinkDisabledKeyIds: nextState.disabledAccountIds - }; - await persistAccountToggle( - nextDraft, - enabled ? () => window.rd.checkAccountCredentials({ kind: "debridlink-api", accountId: key.id }) : undefined - ); - showToast( - enabled - ? `${entry.serviceLabel} ${key.label} aktiviert` - : `${entry.serviceLabel} ${key.label} deaktiviert`, - 2200 - ); - }, (error) => { - showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200); - }); - }; const onAccountRowQuickAction = async (row: AccountTableRow): Promise => { const meta = getAccountQuickActionMeta(row.entry.kind); @@ -3278,113 +3244,23 @@ export function App(): ReactElement { }); }; - const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, enabled: boolean): Promise => { - await performQuickAction(async () => { - const mode = kind === "megadebrid-web" ? "web" : "api"; - const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds; - const nextState = buildScopedAccountEnabledState( - settingsDraft.disabledProviders || [], - ["megadebrid", kind], - current, - accountId, - enabled - ); - const next = nextState.disabledAccountIds; - const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds; - const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds; - await persistAccountToggle({ - ...settingsDraft, - disabledProviders: nextState.disabledProviders, - megaDebridApiEnabled: mode === "api" && enabled ? true : settingsDraft.megaDebridApiEnabled, - megaDebridWebEnabled: mode === "web" && enabled ? true : settingsDraft.megaDebridWebEnabled, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds - }, enabled - ? () => window.rd.checkAccountCredentials({ kind, accountId }) - : undefined - ); - showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000); - }, (error) => { - showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); - }); - }; - const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { - const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); + const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); if (!confirmed) return; await performQuickAction(async () => { - await persistDraftSettings(); - const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id }); - setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); - applyPersistedSettings(result.settings); - showToast("Key entfernt", 2000); + await runQueuedSettingsMutation(async () => { + await persistDraftSettingsDirect(); + const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id }); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); + showToast("Key entfernt", 2000); + }); }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; - const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise => { - await performQuickAction(async () => { - const provider = entry.service as DebridProvider; - const current = settingsDraft.disabledProviders || []; - const nextDisabledProviders = current.includes(provider) - ? current.filter((existing) => existing !== provider) - : [...current, provider]; - const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - disabledProviders: nextDisabledProviders - }; - const enabled = current.includes(provider); - await persistAccountToggle( - nextDraft, - enabled && entry.kind === "deepbrid-api" - ? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" }) - : undefined - ); - showToast( - nextDisabledProviders.includes(provider) - ? `${entry.serviceLabel} deaktiviert` - : `${entry.serviceLabel} aktiviert`, - 2200 - ); - }, (error) => { - showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200); - }); - }; - - const onToggleRealDebridAccountEnabled = async (kind: "realdebrid-api" | "realdebrid-web", accountId: string, enabled: boolean): Promise => { - await performQuickAction(async () => { - const nextState = buildScopedAccountEnabledState( - settingsDraft.disabledProviders || [], - ["realdebrid"], - settingsDraft.realDebridDisabledAccountIds || [], - accountId, - enabled - ); - await persistAccountToggle({ - ...settingsDraft, - disabledProviders: nextState.disabledProviders, - realDebridDisabledAccountIds: nextState.disabledAccountIds - }, enabled - ? () => window.rd.checkAccountCredentials({ kind, accountId }) - : undefined - ); - showToast(enabled ? "Account aktiviert" : "Account deaktiviert", 2000); - }, (error) => { - showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); - }); - }; - - const toggleAccountTableRow = (row: AccountTableRow): void => { + const toggleAccountTableRow = (row: AccountTableRow, enabled = row.disabled): void => { setAccountContextMenu(null); - if (row.toggleKind === "rd" && row.accountId) { - void onToggleRealDebridAccountEnabled(row.entry.kind as "realdebrid-api" | "realdebrid-web", row.accountId, row.disabled); - } else if (row.toggleKind === "mega" && row.accountId) { - void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled); - } else if (row.toggleKind === "dl" && row.dlKey) { - void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey, row.disabled); - } else { - void onToggleAccountEnabled(row.entry); - } + requestAccountToggle(row, enabled); }; const removeAccountTableRows = (rows: readonly AccountTableRow[]): void => { @@ -3410,18 +3286,20 @@ export function App(): ReactElement { return; } await performQuickAction(async () => { - await persistDraftSettings(); - for (const selectedRow of rows) { - const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget)); - setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); - applyPersistedSettings(result.settings); - if (selectedRow.entry.service === "alldebrid") { - setAllDebridHostInfo(null); + await runQueuedSettingsMutation(async () => { + await persistDraftSettingsDirect(); + for (const selectedRow of rows) { + const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget)); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); + if (selectedRow.entry.service === "alldebrid") { + setAllDebridHostInfo(null); + } } - } - const removedRowKeys = new Set(rows.map((selectedRow) => selectedRow.rowKey)); - setSelectedAccountRowKeys((current) => new Set([...current].filter((rowKey) => !removedRowKeys.has(rowKey)))); - showToast(rows.length === 1 ? `${row.hosterLabel} entfernt` : `${rows.length} Accounts entfernt`, 2200); + const removedRowKeys = new Set(rows.map((selectedRow) => selectedRow.rowKey)); + setSelectedAccountRowKeys((current) => new Set([...current].filter((rowKey) => !removedRowKeys.has(rowKey)))); + showToast(rows.length === 1 ? `${row.hosterLabel} entfernt` : `${rows.length} Accounts entfernt`, 2200); + }); }, (error) => { showToast(`${rows.length === 1 ? "Account" : "Accounts"} konnte${rows.length === 1 ? "" : "n"} nicht entfernt werden: ${String(error)}`, 3200); }); @@ -3462,9 +3340,17 @@ export function App(): ReactElement { }); }; - const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise => { + const runQueuedSettingsMutation = async (task: () => Promise): Promise => { + const result = await accountToggleQueueRef.current.enqueue(`settings-mutation-${++settingsMutationSequenceRef.current}`, async () => task()); + if (result.status === "failed") throw result.error; + if (result.status === "superseded") throw new Error("Einstellungsänderung wurde ersetzt"); + return result.value; + }; + + const persistDraftSettingsDirect = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; - const update: RendererSettingsUpdate = { ...normalizedSettingsDraft }; + const rebasedDraft = mergeAccountToggleSettings(normalizedSettingsDraft, persistedSettingsRef.current); + const update: RendererSettingsUpdate = { ...rebasedDraft }; if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; const result = await window.rd.updateSettings(update); @@ -3473,8 +3359,12 @@ export function App(): ReactElement { if (settingsDraftRevisionRef.current === revisionAtStart) { applyPersistedSettings(result, true, themeChoiceAtStart); } - return result; - }; + return result; + }; + + const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise => ( + runQueuedSettingsMutation(() => persistDraftSettingsDirect(themeChoiceAtStart)) + ); const closeStartConflictPrompt = (result: { policy: Extract; applyToAll: boolean } | null): void => { const resolver = startConflictResolverRef.current; @@ -5635,9 +5525,9 @@ export function App(): ReactElement { } setSelectedAccountRowKeys((current) => new Set(updateAccountRowSelection([...current], rowKey, additive))); }, - onToggleEnabled: (rowId) => { + onToggleEnabled: (rowId, enabled) => { const row = accountRowBindings.get(rowId); - if (row) toggleAccountTableRow(row); + if (row) toggleAccountTableRow(row, enabled); }, onEdit: (rowId) => { const row = accountRowBindings.get(rowId); @@ -6007,7 +5897,7 @@ export function App(): ReactElement { if (row) removeAccountTableRow(row); }, onToggleEnabled: () => { - if (accountEditRow) toggleAccountTableRow(accountEditRow); + if (accountEditRow) toggleAccountTableRow(accountEditRow, accountEditRow.disabled); }, onToggleSecret: (fieldId) => { void toggleAccountEditSecret(fieldId); }, onCopySecret: (fieldId) => { void copyAccountEditSecret(fieldId); } @@ -6949,8 +6839,10 @@ export function App(): ReactElement { RG Links - {entry.debridLinkKeys.map((key, ki) => ( -
+ {entry.debridLinkKeys.map((key, ki) => { + const toggleRow = accountRows.find((candidate) => candidate.toggleKind === "dl" && candidate.dlKey?.id === key.id); + const disabled = toggleRow ? toggleRow.disabled : entry.disabled || key.disabled; + return
{(() => { const hostInfo = debridLinkHostLimits[key.id]; const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo); @@ -6971,17 +6863,18 @@ export function App(): ReactElement { {key.masked} {humanSize(key.dailyUsedBytes)} - {entry.disabled || key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"} + {disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"} {statusDisplay.label} {formatDebridLinkTraffic(hostInfo)} {formatDebridLinkCountQuota(hostInfo)} -
- ))} +
; + })}
diff --git a/src/renderer/account-ui.ts b/src/renderer/account-ui.ts index 34a7c66..dd49645 100644 --- a/src/renderer/account-ui.ts +++ b/src/renderer/account-ui.ts @@ -1,4 +1,43 @@ -import type { DebridProvider } from "../shared/types"; +import type { DebridProvider, RendererSettings, RendererSettingsUpdate } from "../shared/types"; + +export type AccountToggleTarget = + | { type: "provider"; provider: DebridProvider } + | { type: "realdebrid"; accountId: string } + | { type: "megadebrid"; provider: "megadebrid-api" | "megadebrid-web"; accountId: string } + | { type: "debridlink"; accountId: string }; + +export type AccountToggleQueueResult = + | { status: "applied"; value: T } + | { status: "failed"; error: unknown } + | { status: "superseded" }; + +export interface AccountToggleQueue { + enqueue(key: string, task: (isCurrent: () => boolean) => Promise): Promise>; +} + +export function createAccountToggleQueue(): AccountToggleQueue { + let tail = Promise.resolve(); + const versions = new Map(); + return { + enqueue(key: string, task: (isCurrent: () => boolean) => Promise): Promise> { + const version = (versions.get(key) || 0) + 1; + versions.set(key, version); + const isCurrent = (): boolean => versions.get(key) === version; + const execute = async (): Promise> => { + if (!isCurrent()) return { status: "superseded" }; + try { + const value = await task(isCurrent); + return isCurrent() ? { status: "applied", value } : { status: "superseded" }; + } catch (error) { + return isCurrent() ? { status: "failed", error } : { status: "superseded" }; + } + }; + const result = tail.then(execute); + tail = result.then(() => undefined); + return result; + } + }; +} export type AccountModeFilter = "all" | "api" | "web"; @@ -139,33 +178,6 @@ export function resolveAccountStatusState( return checkedStatus.isPremium ? "premium" : "free"; } -export async function runOptimisticAccountUpdate( - apply: () => void, - persist: () => Promise, - rollback: () => void -): Promise { - apply(); - try { - return await persist(); - } catch (error) { - rollback(); - throw error; - } -} - -export async function runAccountEnableRefresh( - refresh: (() => Promise<{ valid: boolean; message?: string }>) | undefined, - persist: () => Promise -): Promise { - if (refresh) { - const status = await refresh(); - if (!status.valid) { - throw new Error(status.message || "Accountprüfung fehlgeschlagen"); - } - } - return persist(); -} - export function buildScopedAccountEnabledState( currentDisabledProviders: DebridProvider[], providerIds: DebridProvider[], @@ -183,3 +195,105 @@ export function buildScopedAccountEnabledState( : [...new Set([...currentDisabledAccountIds, accountId])] }; } + +export function buildAccountTogglePatch( + settings: RendererSettings, + target: AccountToggleTarget, + enabled: boolean +): RendererSettingsUpdate { + if (target.type === "provider") { + return { + disabledProviders: enabled + ? settings.disabledProviders.filter((provider) => provider !== target.provider) + : [...new Set([...settings.disabledProviders, target.provider])] + }; + } + if (target.type === "realdebrid") { + const next = buildScopedAccountEnabledState( + settings.disabledProviders, + ["realdebrid"], + settings.realDebridDisabledAccountIds, + target.accountId, + enabled + ); + return { + disabledProviders: next.disabledProviders, + realDebridDisabledAccountIds: next.disabledAccountIds + }; + } + if (target.type === "debridlink") { + const next = buildScopedAccountEnabledState( + settings.disabledProviders, + ["debridlink"], + settings.debridLinkDisabledKeyIds, + target.accountId, + enabled + ); + return { + disabledProviders: next.disabledProviders, + debridLinkDisabledKeyIds: next.disabledAccountIds + }; + } + const web = target.provider === "megadebrid-web"; + const currentDisabledIds = web ? settings.megaDebridWebDisabledAccountIds : settings.megaDebridApiDisabledAccountIds; + const next = buildScopedAccountEnabledState( + settings.disabledProviders, + ["megadebrid", target.provider], + currentDisabledIds, + target.accountId, + enabled + ); + const apiDisabledIds = web ? settings.megaDebridApiDisabledAccountIds : next.disabledAccountIds; + const webDisabledIds = web ? next.disabledAccountIds : settings.megaDebridWebDisabledAccountIds; + return { + disabledProviders: next.disabledProviders, + megaDebridApiEnabled: !web && enabled ? true : settings.megaDebridApiEnabled, + megaDebridWebEnabled: web && enabled ? true : settings.megaDebridWebEnabled, + megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], + megaDebridApiDisabledAccountIds: apiDisabledIds, + megaDebridWebDisabledAccountIds: webDisabledIds + }; +} + +export interface AccountToggleIntent { + key: string; + target: AccountToggleTarget; + enabled: boolean; + check?: () => Promise<{ valid: boolean; message?: string }>; +} + +export interface AccountToggleIntentDependencies { + getSettings: () => RendererSettings; + persist: (patch: RendererSettingsUpdate) => Promise; +} + +export function enqueueAccountToggleIntent( + queue: AccountToggleQueue, + intent: AccountToggleIntent, + dependencies: AccountToggleIntentDependencies +): Promise> { + return queue.enqueue(intent.key, async (isCurrent) => { + if (intent.check) { + const status = await intent.check(); + if (!isCurrent()) return dependencies.getSettings(); + if (!status.valid) throw new Error(status.message || "Accountprüfung fehlgeschlagen"); + } + if (!isCurrent()) return dependencies.getSettings(); + const patch = buildAccountTogglePatch(dependencies.getSettings(), intent.target, intent.enabled); + return dependencies.persist(patch); + }); +} + +export function mergeAccountToggleSettings(current: T, persisted: RendererSettings): T { + return { + ...current, + disabledProviders: [...persisted.disabledProviders], + realDebridDisabledAccountIds: [...persisted.realDebridDisabledAccountIds], + debridLinkDisabledKeyIds: [...persisted.debridLinkDisabledKeyIds], + megaDebridApiEnabled: persisted.megaDebridApiEnabled, + megaDebridWebEnabled: persisted.megaDebridWebEnabled, + megaDebridDisabledAccountIds: [...persisted.megaDebridDisabledAccountIds], + megaDebridApiDisabledAccountIds: [...persisted.megaDebridApiDisabledAccountIds], + megaDebridWebDisabledAccountIds: [...persisted.megaDebridWebDisabledAccountIds] + }; +} diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index ec31c63..acdeb28 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -137,7 +137,7 @@ export interface AccountWorkspaceViewModel { export interface AccountWorkspaceActions { onPanelChange: (panel: AccountWorkspacePanel) => void; onSelect: (rowId: string, additive: boolean) => void; - onToggleEnabled: (rowId: string) => void; + onToggleEnabled: (rowId: string, enabled: boolean) => void; onEdit: (rowId: string) => void; onContextMenu: (rowId: string, x: number, y: number) => void; onCopyIdentity: (label: "Benutzername" | "E-Mail", value: string) => void; @@ -361,7 +361,7 @@ function AccountRow({ aria-label={`${row.hoster} ${row.enabled ? "deaktivieren" : "aktivieren"}`} checked={row.enabled} disabled={busy} - onChange={() => actions.onToggleEnabled(row.id)} + onChange={(event) => actions.onToggleEnabled(row.id, event.target.checked)} onClick={(event) => event.stopPropagation()} type="checkbox" /> diff --git a/tests/account-ui.test.ts b/tests/account-ui.test.ts index 383c057..36e1804 100644 --- a/tests/account-ui.test.ts +++ b/tests/account-ui.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createRendererSettings } from "../src/main/renderer-state"; import { + buildAccountTogglePatch, buildConfiguredProviderOrder, + createAccountToggleQueue, + enqueueAccountToggleIntent, filterAccountDialogOptions, getAvailableAccountOptions, getAccountDialogSelectableOptions, isAccountRowSelectionKey, matchesAccountModeFilter, + mergeAccountToggleSettings, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind, - runAccountEnableRefresh, sortAccountServices } from "../src/renderer/account-ui"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import * as accountUi from "../src/renderer/account-ui"; describe("account mode filter", () => { @@ -136,37 +142,150 @@ describe("account usernames", () => { }); }); -describe("account activation refresh", () => { - it("checks an account before enabling it", async () => { +describe("account toggle bursts", () => { + it("serializes different account intents without dropping the second task", async () => { + const queue = createAccountToggleQueue(); const events: string[] = []; + let releaseFirst: () => void = () => {}; + const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); + const first = queue.enqueue("account-a", async () => { + events.push("a:start"); + await firstGate; + events.push("a:end"); + return "a"; + }); + const second = queue.enqueue("account-b", async () => { + events.push("b:start"); + events.push("b:end"); + return "b"; + }); - await runAccountEnableRefresh( - async () => { events.push("check"); return { valid: true, message: "Premium aktiv" }; }, - async () => { events.push("persist"); } - ); - - expect(events).toEqual(["check", "persist"]); + await Promise.resolve(); + expect(events).toEqual(["a:start"]); + releaseFirst(); + await expect(Promise.all([first, second])).resolves.toEqual([ + { status: "applied", value: "a" }, + { status: "applied", value: "b" } + ]); + expect(events).toEqual(["a:start", "a:end", "b:start", "b:end"]); }); - it("does not check an account while disabling it", async () => { - const events: string[] = []; + it("lets only the latest queued intent for the same account persist", async () => { + const queue = createAccountToggleQueue(); + const persisted: string[] = []; + let releaseBlocker: () => void = () => {}; + const blocker = queue.enqueue("blocker", async () => new Promise((resolve) => { + releaseBlocker = () => resolve("released"); + })); + const stale = queue.enqueue("account-a", async () => { + persisted.push("stale"); + return "stale"; + }); + const latest = queue.enqueue("account-a", async () => { + persisted.push("latest"); + return "latest"; + }); - await runAccountEnableRefresh( - undefined, - async () => { events.push("persist"); } - ); - - expect(events).toEqual(["persist"]); + await Promise.resolve(); + releaseBlocker(); + await blocker; + await expect(stale).resolves.toEqual({ status: "superseded" }); + await expect(latest).resolves.toEqual({ status: "applied", value: "latest" }); + expect(persisted).toEqual(["latest"]); }); - it("does not enable an account whose silent refresh is invalid", async () => { - const events: string[] = []; + it("rebases consecutive account patches on the latest persisted settings", () => { + const source = defaultSettings(); + source.debridLinkApiKeys = "first-key\nsecond-key"; + const settings = createRendererSettings(source); + const [first, second] = parseDebridLinkApiKeys(source.debridLinkApiKeys); + const afterFirst = { ...settings, ...buildAccountTogglePatch(settings, { type: "debridlink", accountId: first.id }, false) }; + const afterSecond = { ...afterFirst, ...buildAccountTogglePatch(afterFirst, { type: "debridlink", accountId: second.id }, false) }; - await expect(runAccountEnableRefresh( - async () => { events.push("check"); return { valid: false, message: "Sitzung abgelaufen" }; }, - async () => { events.push("persist"); } - )).rejects.toThrow("Sitzung abgelaufen"); + expect(afterSecond.debridLinkDisabledKeyIds).toEqual([first.id, second.id]); + }); - expect(events).toEqual(["check"]); + it("does not let a stale enable check overwrite a newer disable intent", async () => { + const queue = createAccountToggleQueue(); + let settings = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" }); + let releaseCheck: () => void = () => {}; + const checkGate = new Promise((resolve) => { releaseCheck = resolve; }); + const persistedPatches: unknown[] = []; + const dependencies = { + getSettings: () => settings, + persist: async (patch: ReturnType) => { + persistedPatches.push(patch); + settings = { ...settings, ...patch }; + return settings; + } + }; + const target = { type: "provider", provider: "deepbrid" } as const; + const enable = enqueueAccountToggleIntent(queue, { + key: "svc-deepbrid", + target, + enabled: true, + check: async () => { + await checkGate; + return { valid: true }; + } + }, dependencies); + + await Promise.resolve(); + const disable = enqueueAccountToggleIntent(queue, { key: "svc-deepbrid", target, enabled: false }, dependencies); + releaseCheck(); + + await expect(enable).resolves.toEqual({ status: "superseded" }); + await expect(disable).resolves.toEqual(expect.objectContaining({ status: "applied" })); + expect(persistedPatches).toHaveLength(1); + expect(settings.disabledProviders).toContain("deepbrid"); + }); + + it("continues with the next account after an earlier toggle fails", async () => { + const queue = createAccountToggleQueue(); + const first = queue.enqueue("account-a", async () => { throw new Error("invalid account"); }); + const second = queue.enqueue("account-b", async () => "saved-b"); + + await expect(first).resolves.toEqual(expect.objectContaining({ status: "failed" })); + await expect(second).resolves.toEqual({ status: "applied", value: "saved-b" }); + }); + + it("rebases a full settings save onto the latest persisted account state", () => { + const base = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" }); + const draft = { ...base, theme: "light" as const, disabledProviders: [] }; + const persisted = { ...base, theme: "dark" as const, disabledProviders: ["deepbrid" as const] }; + + expect(mergeAccountToggleSettings(draft, persisted)).toEqual(expect.objectContaining({ + theme: "light", + disabledProviders: ["deepbrid"] + })); + }); + + it("preserves a queued account toggle when a full settings save follows immediately", async () => { + const queue = createAccountToggleQueue(); + let settings = createRendererSettings({ ...defaultSettings(), deepbridApiKey: "test-key" }); + const draft = { ...settings, theme: "light" as const }; + const dependencies = { + getSettings: () => settings, + persist: async (patch: ReturnType) => { + settings = { ...settings, ...patch }; + return settings; + } + }; + const toggle = enqueueAccountToggleIntent(queue, { + key: "svc-deepbrid", + target: { type: "provider", provider: "deepbrid" }, + enabled: false + }, dependencies); + const fullSave = queue.enqueue("settings-save-1", async () => { + settings = mergeAccountToggleSettings(draft, settings); + return settings; + }); + + await expect(toggle).resolves.toEqual(expect.objectContaining({ status: "applied" })); + await expect(fullSave).resolves.toEqual(expect.objectContaining({ status: "applied" })); + expect(settings).toEqual(expect.objectContaining({ + theme: "light", + disabledProviders: ["deepbrid"] + })); }); }); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 4da0708..e809250 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -7,10 +7,10 @@ import { createRendererSettings, createRendererState } from "../src/main/rendere import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App"; import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit"; import { + buildAccountTogglePatch, buildScopedAccountEnabledState, buildConfiguredProviderOrder, - resolveAccountStatusState, - runOptimisticAccountUpdate + resolveAccountStatusState } from "../src/renderer/account-ui"; import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; @@ -1048,7 +1048,7 @@ describe("account workspace", () => { model: workspaceModel(), actions: workspaceActions({ onSelect: (id, additive) => calls.push(`select:${id}:${additive}`), - onToggleEnabled: (id) => calls.push(`toggle:${id}`), + onToggleEnabled: (id, enabled) => calls.push(`toggle:${id}:${enabled}`), onEdit: (id) => calls.push(`edit:${id}`), onContextMenu: (id) => calls.push(`context:${id}`) }) @@ -1062,7 +1062,7 @@ describe("account workspace", () => { row.props.onClick({ target: { role: "cell" }, currentTarget: row, ctrlKey: true, metaKey: false }); row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} }); row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} }); - checkbox.props.onChange(); + checkbox.props.onChange({ target: { checked: false } }); row.props.onDoubleClick(); actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } }); actionButton.props.onDoubleClick({ stopPropagation: () => calls.push("action-double-click-stopped") }); @@ -1071,7 +1071,7 @@ describe("account workspace", () => { `select:${rowId}:false`, `select:${rowId}:true`, `select:${rowId}:false`, - `toggle:${rowId}`, + `toggle:${rowId}:false`, `edit:${rowId}`, `context:${rowId}`, "action-double-click-stopped" @@ -1432,10 +1432,10 @@ describe("settings App integration", () => { const deleteKeyBlock = sourceBlock(appSource, "const onRemoveDebridLinkKey", "const onToggleAccountEnabled"); const deleteRowsBlock = sourceBlock(appSource, "const removeAccountTableRows", "const checkAccountsActive"); - expect(editBlock.indexOf("await persistDraftSettings()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount")); - expect(createBlock.indexOf("await persistDraftSettings()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount")); - expect(deleteKeyBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount")); - expect(deleteRowsBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount")); + expect(editBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount")); + expect(createBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount")); + expect(deleteKeyBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount")); + expect(deleteRowsBlock.indexOf("await persistDraftSettingsDirect()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount")); }); it("invalidates an archive password reveal before applying imported settings", () => { @@ -1462,12 +1462,18 @@ describe("settings App integration", () => { disabledAccountIds: ["key-active"] }); - const debridLinkBlock = sourceBlock(appSource, "const onToggleDebridLinkApiKeyEnabled", "const onAccountRowQuickAction"); - const megaBlock = sourceBlock(appSource, "const onToggleMegaAccountEnabled", "const onRemoveDebridLinkKey"); - expect(debridLinkBlock).toContain("buildScopedAccountEnabledState"); - expect(megaBlock).toContain("buildScopedAccountEnabledState"); - expect(megaBlock).toContain('megaDebridApiEnabled: mode === "api" && enabled ? true'); - expect(megaBlock).toContain('megaDebridWebEnabled: mode === "web" && enabled ? true'); + const rendererSettings = createRendererSettings(defaultSettings()); + const megaPatch = buildAccountTogglePatch({ + ...rendererSettings, + disabledProviders: ["megadebrid-web"], + megaDebridWebEnabled: false, + megaDebridWebDisabledAccountIds: ["mega-web-account"] + }, { type: "megadebrid", provider: "megadebrid-web", accountId: "mega-web-account" }, true); + expect(megaPatch).toEqual(expect.objectContaining({ + disabledProviders: [], + megaDebridWebEnabled: true, + megaDebridWebDisabledAccountIds: [] + })); }); it("shows a failed all-account check even when the account is disabled", () => { @@ -1476,28 +1482,6 @@ describe("settings App integration", () => { expect(resolveAccountStatusState(false, undefined)).toBe("unchecked"); }); - it("applies account switches before persistence settles and rolls back failed saves", async () => { - const events: string[] = []; - let resolvePersist: (value: string) => void = () => { throw new Error("persist resolver missing"); }; - const pending = runOptimisticAccountUpdate( - () => events.push("apply"), - () => new Promise((resolve) => { resolvePersist = resolve; }), - () => events.push("rollback") - ); - - expect(events).toEqual(["apply"]); - resolvePersist("saved"); - await expect(pending).resolves.toBe("saved"); - expect(events).toEqual(["apply"]); - - await expect(runOptimisticAccountUpdate( - () => events.push("apply-failed"), - async () => { throw new Error("save failed"); }, - () => events.push("rollback") - )).rejects.toThrow("save failed"); - expect(events.slice(-2)).toEqual(["apply-failed", "rollback"]); - }); - it("keeps new Mega-Debrid credentials empty and never exposes stored accounts as an API key", () => { const settings = { ...defaultSettings(), @@ -1517,24 +1501,6 @@ describe("settings App integration", () => { expect(debridLinkFields.find((field) => field.id === "token")).toEqual(expect.objectContaining({ value: "" })); }); - it("keeps specific persistence revision-safe when the draft changes in flight", () => { - const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction"); - const toggleBlock = sourceBlock(appSource, "const persistAccountToggle", "const onToggleDebridLinkApiKeyEnabled"); - expect(block).toContain("revisionAtStart"); - expect(block).toContain("mergeConcurrentSpecificSettings"); - expect(block).toContain('setSettingsSaveState("dirty")'); - expect(block).toContain("persistedSettingsRef.current = result"); - expect(block.indexOf("persistedSettingsRef.current = result")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)")); - expect(block).toContain("themeChoiceAtStart: settingsThemeChoiceRef.current"); - expect(block).toContain("const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext"); - expect(block).toContain("persistedThemeChoiceRef.current = themeChoiceAtStart"); - expect(block.indexOf("persistedThemeChoiceRef.current = themeChoiceAtStart")).toBeLessThan(block.indexOf("if (settingsDraftRevisionRef.current === revisionAtStart)")); - expect(toggleBlock).toContain("const themeChoiceAtStart = settingsThemeChoiceRef.current"); - expect(toggleBlock).toContain("const persistenceContext = {"); - expect(toggleBlock.indexOf("const persistenceContext = {")).toBeLessThan(toggleBlock.indexOf("runAccountEnableRefresh(")); - expect(toggleBlock).toContain("persistSpecificSettings(nextDraft, persistenceContext)"); - }); - it("keeps unchecked single accounts honest without a positive status", () => { const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId"); expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow)");