feat(settings): discard unsaved changes

This commit is contained in:
Sucukdeluxe
2026-08-25 04:53:53 +02:00
parent 5a04a62945
commit bd2808a763
6 changed files with 244 additions and 43 deletions
+131 -26
View File
@@ -339,6 +339,33 @@ export function createSettingsDraft(settings: RendererSettings, current?: Render
}; };
} }
export function createDiscardedSettingsState(settings: RendererSettings, themeChoice: SettingsThemeChoice = settings.theme): {
draft: RendererSettingsDraft;
themeChoice: SettingsThemeChoice;
speedLimitInput: string;
scheduleSpeedInputs: Record<string, string>;
} {
return {
draft: createSettingsDraft(settings),
themeChoice,
speedLimitInput: formatMbpsInputFromKbps(settings.speedLimitKbps),
scheduleSpeedInputs: Object.fromEntries(
(settings.bandwidthSchedules || []).map((schedule) => [schedule.id, formatMbpsInputFromKbps(schedule.speedLimitKbps)])
)
};
}
export function resolveSettingsSaveCompletion(revisionAtStart: number, currentRevision: number): {
saveState: Extract<SettingsSaveState, "saved" | "dirty">;
applyPersistedTheme: boolean;
toast: string;
} {
const unchanged = revisionAtStart === currentRevision;
return unchanged
? { saveState: "saved", applyPersistedTheme: true, toast: "Einstellungen gespeichert" }
: { saveState: "dirty", applyPersistedTheme: false, toast: "Zwischenstand gespeichert weitere Änderungen sind ungespeichert" };
}
function settingsValueEqual(left: unknown, right: unknown): boolean { function settingsValueEqual(left: unknown, right: unknown): boolean {
return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right);
} }
@@ -1670,6 +1697,7 @@ export function App(): ReactElement {
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({}); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
const [settingsDirty, setSettingsDirty] = useState(false); const [settingsDirty, setSettingsDirty] = useState(false);
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean"); const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
const [settingsSaveInFlight, setSettingsSaveInFlight] = useState(false);
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState(""); const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today"); const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today");
@@ -1678,6 +1706,7 @@ export function App(): ReactElement {
const updateCheckGenerationRef = useRef(0); const updateCheckGenerationRef = useRef(0);
const dismissedUpdateTagRef = useRef(""); const dismissedUpdateTagRef = useRef("");
const settingsDirtyRef = useRef(false); const settingsDirtyRef = useRef(false);
const settingsSaveInFlightRef = useRef(false);
const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">()); const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">());
const archivePasswordLoadGenerationRef = useRef(0); const archivePasswordLoadGenerationRef = useRef(0);
const settingsDraftRevisionRef = useRef(0); const settingsDraftRevisionRef = useRef(0);
@@ -1687,10 +1716,14 @@ export function App(): ReactElement {
return () => localizer.disconnect(); return () => localizer.disconnect();
}, [settingsDraft.language]); }, [settingsDraft.language]);
const panelDirtyRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null); const latestStateRef = useRef<UiSnapshot | null>(null);
const masterSnapshotRef = useRef<UiSnapshot | null>(null); const masterSnapshotRef = useRef<UiSnapshot | null>(null);
const snapshotRef = useRef(snapshot); const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot; const persistedSettingsRef = useRef<RendererSettings>(emptySnapshot().settings);
const settingsThemeChoiceRef = useRef<SettingsThemeChoice>(settingsThemeChoice);
const persistedThemeChoiceRef = useRef<SettingsThemeChoice>(emptySnapshot().settings.theme);
snapshotRef.current = snapshot;
settingsThemeChoiceRef.current = settingsThemeChoice;
const tabRef = useRef(tab); const tabRef = useRef(tab);
tabRef.current = tab; tabRef.current = tab;
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -2129,6 +2162,8 @@ export function App(): ReactElement {
return; return;
} }
masterSnapshotRef.current = state; masterSnapshotRef.current = state;
persistedSettingsRef.current = state.settings;
persistedThemeChoiceRef.current = state.settings.theme;
setSnapshot(state); setSnapshot(state);
if (state.settings.columnOrder?.length > 0) { if (state.settings.columnOrder?.length > 0) {
columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder); columnOrderPersistenceRef.current?.applyAuthoritative(state.settings.columnOrder);
@@ -2140,6 +2175,7 @@ export function App(): ReactElement {
setSettingsDirty(false); setSettingsDirty(false);
setSettingsSaveState("clean"); setSettingsSaveState("clean");
setSettingsThemeChoice(state.settings.theme); setSettingsThemeChoice(state.settings.theme);
settingsThemeChoiceRef.current = state.settings.theme;
applyTheme(state.settings.theme); applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) { if (state.settings.autoUpdateCheck) {
void runLatestUpdateCheck( void runLatestUpdateCheck(
@@ -2174,7 +2210,8 @@ export function App(): ReactElement {
} else { } else {
merged = wireState; merged = wireState;
} }
masterSnapshotRef.current = merged; masterSnapshotRef.current = merged;
persistedSettingsRef.current = merged.settings;
latestStateRef.current = merged; latestStateRef.current = merged;
if (stateFlushTimerRef.current) { return; } if (stateFlushTimerRef.current) { return; }
@@ -2809,16 +2846,57 @@ export function App(): ReactElement {
return; return;
} }
const revisionAtStart = settingsDraftRevisionRef.current; const revisionAtStart = settingsDraftRevisionRef.current;
const themeChoiceAtStart = settingsThemeChoiceRef.current;
settingsSaveInFlightRef.current = true;
setSettingsSaveInFlight(true);
setSettingsSaveState("saving"); setSettingsSaveState("saving");
await performQuickAction(async () => { try {
const result = await persistDraftSettings(); await performQuickAction(async () => {
applyTheme(result.theme); const result = await persistDraftSettings(themeChoiceAtStart);
setSettingsSaveState(settingsDraftRevisionRef.current === revisionAtStart ? "saved" : "dirty"); const completion = resolveSettingsSaveCompletion(revisionAtStart, settingsDraftRevisionRef.current);
showToast("Einstellungen gespeichert", 1800); if (completion.applyPersistedTheme) {
}, (error) => { applyTheme(result.theme);
setSettingsSaveState("error"); }
showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); setSettingsSaveState(completion.saveState);
}); showToast(completion.toast, 1800);
}, (error) => {
setSettingsSaveState("error");
showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800);
});
} finally {
settingsSaveInFlightRef.current = false;
setSettingsSaveInFlight(false);
}
};
const discardSettingsChanges = (): void => {
if (settingsSaveInFlightRef.current || actionBusyRef.current || settingsSaveState === "saving" || !settingsDirtyRef.current) {
return;
}
const restored = createDiscardedSettingsState(persistedSettingsRef.current, persistedThemeChoiceRef.current);
settingsDraftRevisionRef.current += 1;
archivePasswordLoadGenerationRef.current += 1;
panelDirtyRevisionRef.current = 0;
writeOnlySettingsDirtyRef.current.clear();
settingsDirtyRef.current = false;
setSettingsDirty(false);
setSettingsSaveState("clean");
setSettingsDraft(restored.draft);
setSettingsThemeChoice(restored.themeChoice);
settingsThemeChoiceRef.current = restored.themeChoice;
setSpeedLimitInput(restored.speedLimitInput);
setScheduleSpeedInputs(restored.scheduleSpeedInputs);
applyTheme(restored.draft.theme);
showToast("Ungespeicherte Änderungen verworfen", 1800);
if (settingsSubTab === "extract") {
const generation = archivePasswordLoadGenerationRef.current;
void window.rd.getArchivePasswordList().then(({ passwords }) => {
if (archivePasswordLoadGenerationRef.current !== generation || writeOnlySettingsDirtyRef.current.has("archivePasswordList")) {
return;
}
setSettingsDraft((current) => ({ ...current, archivePasswordList: passwords }));
}).catch(() => undefined);
}
}; };
const onOpenRealDebridLogin = async (): Promise<void> => { const onOpenRealDebridLogin = async (): Promise<void> => {
@@ -2855,7 +2933,13 @@ export function App(): ReactElement {
}); });
}; };
const applyPersistedSettings = (result: RendererSettings, preserveWriteOnlyValues = true): void => { const applyPersistedSettings = (
result: RendererSettings,
preserveWriteOnlyValues = true,
themeChoice: SettingsThemeChoice = settingsThemeChoiceRef.current === "system" ? "system" : result.theme
): void => {
persistedSettingsRef.current = result;
persistedThemeChoiceRef.current = themeChoice;
if (!preserveWriteOnlyValues) { if (!preserveWriteOnlyValues) {
archivePasswordLoadGenerationRef.current += 1; archivePasswordLoadGenerationRef.current += 1;
} }
@@ -2868,12 +2952,14 @@ export function App(): ReactElement {
panelDirtyRevisionRef.current = 0; panelDirtyRevisionRef.current = 0;
setSettingsDirty(false); setSettingsDirty(false);
setSettingsSaveState("clean"); setSettingsSaveState("clean");
setSettingsThemeChoice((current) => current === "system" ? current : result.theme); setSettingsThemeChoice(themeChoice);
settingsThemeChoiceRef.current = themeChoice;
applyTheme(result.theme); applyTheme(result.theme);
}; };
const syncLiveProviderUsageSettings = (result: RendererSettings): void => { const syncLiveProviderUsageSettings = (result: RendererSettings): void => {
setSnapshot((prev) => ({ ...prev, settings: result })); persistedSettingsRef.current = result;
setSnapshot((prev) => ({ ...prev, settings: result }));
if (!settingsDirtyRef.current) { if (!settingsDirtyRef.current) {
applyPersistedSettings(result); applyPersistedSettings(result);
return; return;
@@ -2891,9 +2977,15 @@ export function App(): ReactElement {
})); }));
}; };
const persistSpecificSettings = async (nextDraft: RendererSettingsDraft): Promise<RendererSettings> => { const persistSpecificSettings = async (
const revisionAtStart = settingsDraftRevisionRef.current; nextDraft: RendererSettingsDraft,
const draftAtStart = settingsDraft; persistenceContext = {
revisionAtStart: settingsDraftRevisionRef.current,
draftAtStart: settingsDraft,
themeChoiceAtStart: settingsThemeChoiceRef.current
}
): Promise<RendererSettings> => {
const { revisionAtStart, draftAtStart, themeChoiceAtStart } = persistenceContext;
const normalizedDraft = { const normalizedDraft = {
...nextDraft, ...nextDraft,
...normalizeProviderSelectionForSettings(nextDraft) ...normalizeProviderSelectionForSettings(nextDraft)
@@ -2902,8 +2994,10 @@ export function App(): ReactElement {
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
const result = await window.rd.updateSettings(update); const result = await window.rd.updateSettings(update);
persistedSettingsRef.current = result;
persistedThemeChoiceRef.current = themeChoiceAtStart;
if (settingsDraftRevisionRef.current === revisionAtStart) { if (settingsDraftRevisionRef.current === revisionAtStart) {
applyPersistedSettings(result); applyPersistedSettings(result, true, themeChoiceAtStart);
} else { } else {
setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current)); setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current));
settingsDirtyRef.current = true; settingsDirtyRef.current = true;
@@ -3103,7 +3197,13 @@ export function App(): ReactElement {
const previousDraft = settingsDraft; const previousDraft = settingsDraft;
const previousDirty = settingsDirtyRef.current; const previousDirty = settingsDirtyRef.current;
const previousSaveState = settingsSaveState; const previousSaveState = settingsSaveState;
const themeChoiceAtStart = settingsThemeChoiceRef.current;
const revision = ++settingsDraftRevisionRef.current; const revision = ++settingsDraftRevisionRef.current;
const persistenceContext = {
revisionAtStart: revision,
draftAtStart: previousDraft,
themeChoiceAtStart
};
return runOptimisticAccountUpdate( return runOptimisticAccountUpdate(
() => { () => {
settingsDirtyRef.current = true; settingsDirtyRef.current = true;
@@ -3113,7 +3213,7 @@ export function App(): ReactElement {
}, },
() => runAccountEnableRefresh( () => runAccountEnableRefresh(
refreshBeforePersist, refreshBeforePersist,
() => persistSpecificSettings(nextDraft) () => persistSpecificSettings(nextDraft, persistenceContext)
), ),
() => { () => {
if (settingsDraftRevisionRef.current !== revision) return; if (settingsDraftRevisionRef.current !== revision) return;
@@ -3351,15 +3451,17 @@ export function App(): ReactElement {
}); });
}; };
const persistDraftSettings = async (): Promise<RendererSettings> => { const persistDraftSettings = async (themeChoiceAtStart: SettingsThemeChoice = settingsThemeChoiceRef.current): Promise<RendererSettings> => {
const revisionAtStart = settingsDraftRevisionRef.current; const revisionAtStart = settingsDraftRevisionRef.current;
const update: RendererSettingsUpdate = { ...normalizedSettingsDraft }; const update: RendererSettingsUpdate = { ...normalizedSettingsDraft };
if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList;
if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl;
const result = await window.rd.updateSettings(update); const result = await window.rd.updateSettings(update);
if (settingsDraftRevisionRef.current === revisionAtStart) { persistedSettingsRef.current = result;
applyPersistedSettings(result); persistedThemeChoiceRef.current = themeChoiceAtStart;
} if (settingsDraftRevisionRef.current === revisionAtStart) {
applyPersistedSettings(result, true, themeChoiceAtStart);
}
return result; return result;
}; };
@@ -4959,6 +5061,7 @@ export function App(): ReactElement {
const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => { const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => {
masterSnapshotRef.current = state; masterSnapshotRef.current = state;
persistedSettingsRef.current = state.settings;
latestStateRef.current = null; latestStateRef.current = null;
snapshotRef.current = state; snapshotRef.current = state;
setSnapshot(state); setSnapshot(state);
@@ -5717,11 +5820,13 @@ export function App(): ReactElement {
const settingsViewModel: SettingsViewModel = { const settingsViewModel: SettingsViewModel = {
section: settingsSubTab, section: settingsSubTab,
saveState: settingsDirty && settingsSaveState === "clean" ? "dirty" : settingsSaveState, saveState: settingsDirty && settingsSaveState === "clean" ? "dirty" : settingsSaveState,
saveInFlight: settingsSaveInFlight,
form: settingsFormModel, form: settingsFormModel,
accounts: accountWorkspaceModel accounts: accountWorkspaceModel
}; };
const settingsViewActions: SettingsViewActions = { const settingsViewActions: SettingsViewActions = {
onSectionChange: setSettingsSubTab, onSectionChange: setSettingsSubTab,
onDiscard: discardSettingsChanges,
onSave: () => { void onSaveSettings(); }, onSave: () => { void onSaveSettings(); },
form: settingsFormActions, form: settingsFormActions,
accounts: accountWorkspaceActions accounts: accountWorkspaceActions
+1 -1
View File
@@ -6,7 +6,7 @@ const pairs = [
["Hauptnavigation", "Main navigation"], ["Globale Aktionen", "Global actions"], ["Anwendungsmenü", "Application menu"], ["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"], ["Hauptnavigation", "Main navigation"], ["Globale Aktionen", "Global actions"], ["Anwendungsmenü", "Application menu"], ["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"],
["Aktuelle Download-Geschwindigkeit (geglättet)", "Current download speed (smoothed)"], ["Einstellungsbereich", "Settings area"], ["Aktuelle Download-Geschwindigkeit (geglättet)", "Current download speed (smoothed)"], ["Einstellungsbereich", "Settings area"],
["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"], ["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"],
["Einstellungen speichern", "Save settings"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"], ["Einstellungen speichern", "Save settings"], ["Änderungen verwerfen", "Discard changes"], ["Stellt den letzten gespeicherten Stand wieder her.", "Restores the last saved settings."], ["Ungespeicherte Änderungen verworfen", "Unsaved changes discarded"], ["Zwischenstand gespeichert weitere Änderungen sind ungespeichert", "Progress saved additional changes remain unsaved"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"], ["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"],
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."], ["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"], ["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
+26 -14
View File
@@ -17,16 +17,18 @@ import "./settings.css";
export type SettingsViewRegion = "all" | "sidebar" | "content"; export type SettingsViewRegion = "all" | "sidebar" | "content";
export interface SettingsViewModel { export interface SettingsViewModel {
section: SettingsSection; section: SettingsSection;
saveState: SettingsSaveState; saveState: SettingsSaveState;
saveInFlight: boolean;
form: SettingsFormViewModel; form: SettingsFormViewModel;
accounts: AccountWorkspaceViewModel; accounts: AccountWorkspaceViewModel;
} }
export interface SettingsViewActions { export interface SettingsViewActions {
onSectionChange: (section: SettingsSection) => void; onSectionChange: (section: SettingsSection) => void;
onSave: () => void; onDiscard: () => void;
onSave: () => void;
form: SettingsFormActions; form: SettingsFormActions;
accounts: AccountWorkspaceActions; accounts: AccountWorkspaceActions;
} }
@@ -61,8 +63,9 @@ export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactEle
} }
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement { export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
const saveLabel = getSettingsSaveLabel(model.saveState); const saveLabel = getSettingsSaveLabel(model.saveState);
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving"; const saveDisabled = model.saveInFlight || model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
const discardDisabled = model.saveInFlight || model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
return ( return (
<section aria-label="Einstellungsbereich" className="settings-content settings-static"> <section aria-label="Einstellungsbereich" className="settings-content settings-static">
<header className="settings-content-header"> <header className="settings-content-header">
@@ -70,12 +73,21 @@ export function SettingsContent({ model, actions }: SettingsViewProps): ReactEle
<h1>Einstellungen</h1> <h1>Einstellungen</h1>
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span> <span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
</div> </div>
<button <div className="settings-content-actions">
className="settings-button settings-button-primary settings-save-button" <button
disabled={saveDisabled} className="settings-button settings-button-secondary settings-discard-button"
onClick={actions.onSave} disabled={discardDisabled}
type="button" onClick={actions.onDiscard}
>Einstellungen speichern</button> title="Stellt den letzten gespeicherten Stand wieder her."
type="button"
>Änderungen verwerfen</button>
<button
className="settings-button settings-button-primary settings-save-button"
disabled={saveDisabled}
onClick={actions.onSave}
type="button"
>Einstellungen speichern</button>
</div>
</header> </header>
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}> <div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
{model.section === "accounts" {model.section === "accounts"
+7
View File
@@ -98,6 +98,13 @@
gap: 12px; gap: 12px;
} }
.settings-content-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.settings-content-header h1, .settings-content-header h1,
.settings-form-heading h2, .settings-form-heading h2,
.settings-account-heading h2, .settings-account-heading h2,
+4
View File
@@ -11,6 +11,10 @@ describe("renderer localization", () => {
it("translates exact interface labels in both directions", () => { it("translates exact interface labels in both directions", () => {
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings"); expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern"); expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
expect(translateUiText("Änderungen verwerfen", "en")).toBe("Discard changes");
expect(translateUiText("Discard changes", "de")).toBe("Änderungen verwerfen");
expect(translateUiText("Ungespeicherte Änderungen verworfen", "en")).toBe("Unsaved changes discarded");
expect(translateUiText("Zwischenstand gespeichert weitere Änderungen sind ungespeichert", "en")).toBe("Progress saved additional changes remain unsaved");
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access"); expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
expect(translateUiText("Animationen", "en")).toBe("Animations"); expect(translateUiText("Animationen", "en")).toBe("Animations");
expect(translateUiText("Animations", "de")).toBe("Animationen"); expect(translateUiText("Animations", "de")).toBe("Animationen");
+75 -2
View File
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { createRendererSettings, createRendererState } from "../src/main/renderer-state"; import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, createAccountDialogState, createSettingsDraft } from "../src/renderer/App"; import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit"; import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
import { import {
buildScopedAccountEnabledState, buildScopedAccountEnabledState,
@@ -342,10 +342,11 @@ function workspaceActions(overrides: Partial<AccountWorkspaceActions> = {}): Acc
}; };
} }
function viewModel(saveState: SettingsViewModel["saveState"] = "clean"): SettingsViewModel { function viewModel(saveState: SettingsViewModel["saveState"] = "clean", saveInFlight = false): SettingsViewModel {
return { return {
section: "accounts", section: "accounts",
saveState, saveState,
saveInFlight,
form: formModel(), form: formModel(),
accounts: workspaceModel() accounts: workspaceModel()
}; };
@@ -354,6 +355,7 @@ function viewModel(saveState: SettingsViewModel["saveState"] = "clean"): Setting
function viewActions(): SettingsViewActions { function viewActions(): SettingsViewActions {
return { return {
onSectionChange: () => {}, onSectionChange: () => {},
onDiscard: () => {},
onSave: () => {}, onSave: () => {},
form: { onChange: () => {}, onAction: () => {} }, form: { onChange: () => {}, onAction: () => {} },
accounts: workspaceActions() accounts: workspaceActions()
@@ -778,6 +780,37 @@ describe("settings views", () => {
} }
}); });
it("enables discarding only for unsaved or failed settings drafts", () => {
let discarded = 0;
const actions = { ...viewActions(), onDiscard: () => { discarded += 1; } };
const findDiscard = (content: ReactElement): ReactElement<{ disabled?: boolean; onClick: () => void }> => {
let result: ReactElement<{ disabled?: boolean; onClick: () => void }> | null = null;
visitElements(content, (element) => {
if (element.type === "button" && element.props.children === "Änderungen verwerfen") {
result = element as ReactElement<{ disabled?: boolean; onClick: () => void }>;
}
});
if (!result) throw new Error("Discard button missing");
return result;
};
const dirty = findDiscard(SettingsContent({ actions, model: viewModel("dirty") }));
const clean = findDiscard(SettingsContent({ actions, model: viewModel("clean") }));
const saving = findDiscard(SettingsContent({ actions, model: viewModel("saving") }));
const saved = findDiscard(SettingsContent({ actions, model: viewModel("saved") }));
const error = findDiscard(SettingsContent({ actions, model: viewModel("error") }));
const dirtyWhileSaving = findDiscard(SettingsContent({ actions, model: viewModel("dirty", true) }));
expect(dirty.props.disabled).toBe(false);
expect(clean.props.disabled).toBe(true);
expect(saving.props.disabled).toBe(true);
expect(saved.props.disabled).toBe(true);
expect(error.props.disabled).toBe(false);
expect(dirtyWhileSaving.props.disabled).toBe(true);
dirty.props.onClick();
expect(discarded).toBe(1);
});
it("renders the settings save action with a green background and black text", () => { it("renders the settings save action with a green background and black text", () => {
const html = renderToStaticMarkup(<SettingsContent actions={viewActions()} model={viewModel("dirty")} />); const html = renderToStaticMarkup(<SettingsContent actions={viewActions()} model={viewModel("dirty")} />);
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
@@ -1323,6 +1356,35 @@ describe("settings App integration", () => {
expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)"); expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)");
}); });
it("rebuilds every local settings input from the last persisted state", () => {
const persisted = createRendererSettings({
...defaultSettings(),
theme: "light",
speedLimitKbps: 12 * 1024,
bandwidthSchedules: [{ id: "night", startHour: 22, endHour: 6, speedLimitKbps: 8 * 1024, enabled: true }]
});
expect(createDiscardedSettingsState(persisted, "system")).toEqual({
draft: { ...persisted, archivePasswordList: "", notifyUrl: "" },
themeChoice: "system",
speedLimitInput: "12",
scheduleSpeedInputs: { night: "8" }
});
});
it("does not apply a stale save result over newer draft changes", () => {
expect(resolveSettingsSaveCompletion(4, 4)).toEqual({
saveState: "saved",
applyPersistedTheme: true,
toast: "Einstellungen gespeichert"
});
expect(resolveSettingsSaveCompletion(4, 5)).toEqual({
saveState: "dirty",
applyPersistedTheme: false,
toast: "Zwischenstand gespeichert weitere Änderungen sind ungespeichert"
});
});
it("loads and preserves the stored archive password list in the extraction section", () => { it("loads and preserves the stored archive password list in the extraction section", () => {
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener"); const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings"); const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
@@ -1429,9 +1491,20 @@ describe("settings App integration", () => {
it("keeps specific persistence revision-safe when the draft changes in flight", () => { it("keeps specific persistence revision-safe when the draft changes in flight", () => {
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction"); const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
const toggleBlock = sourceBlock(appSource, "const persistAccountToggle", "const onToggleDebridLinkApiKeyEnabled");
expect(block).toContain("revisionAtStart"); expect(block).toContain("revisionAtStart");
expect(block).toContain("mergeConcurrentSpecificSettings"); expect(block).toContain("mergeConcurrentSpecificSettings");
expect(block).toContain('setSettingsSaveState("dirty")'); 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", () => { it("keeps unchecked single accounts honest without a positive status", () => {