From e7076a76c222c23541db81328cbd4b16927e1d43 Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 22:44:11 -0400 Subject: [PATCH] fix(config): mark app.json dirty on any change, even one later reverted PLAN 4.3 writes app.json when the session modified a setting. The store compared the live configuration with the loaded one instead, so a setting changed and changed back was never written. Each section now records a dirty flag in its setters; assigning the current value is not a change and the load-time field fallbacks are cleared after reading. --- .../Storage/AppData/AppConfigStoreTests.cs | 54 ++++++++++-- YKanBan/Services/AppServices.cs | 2 +- YKanBan/Storage/AppData/AppConfig.cs | 82 +++++++++++++++---- YKanBan/Storage/AppData/AppConfigStore.cs | 35 ++++---- docs/ACCEPTANCE.md | 1 + docs/PLAN.md | 4 +- 6 files changed, 132 insertions(+), 46 deletions(-) diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs index 74d832d..c04a37f 100644 --- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -289,7 +289,7 @@ public class AppConfigStoreTests AppConfig config = store.Load(); - Assert.IsFalse(store.SaveIfChanged(config)); + Assert.IsFalse(store.SaveIfDirty(config)); Assert.IsFalse(File.Exists(path)); } @@ -304,7 +304,7 @@ public class AppConfigStoreTests AppConfig config = store.Load(); - Assert.IsFalse(store.SaveIfChanged(config)); + Assert.IsFalse(store.SaveIfDirty(config)); Assert.AreEqual(original, File.ReadAllText(path)); } @@ -318,15 +318,15 @@ public class AppConfigStoreTests config.Sort.Card = CardSortOption.UpdatedAt; - Assert.IsTrue(store.SaveIfChanged(config)); + Assert.IsTrue(store.SaveIfDirty(config)); Assert.AreEqual(CardSortOption.UpdatedAt, new AppConfigStore(path).Load().Sort.Card); // Saved state is the new baseline. - Assert.IsFalse(store.SaveIfChanged(config)); + Assert.IsFalse(store.SaveIfDirty(config)); } [TestMethod] - public void ChangeRevertedWithinSessionIsNotWritten() + public void ChangeRevertedWithinSessionIsStillWritten() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); @@ -336,7 +336,49 @@ public class AppConfigStoreTests config.Theme = ThemeOption.Dark; config.Theme = ThemeOption.FollowSystem; - Assert.IsFalse(store.SaveIfChanged(config)); + Assert.IsTrue(store.SaveIfDirty(config)); + Assert.IsTrue(File.Exists(path)); + } + + [TestMethod] + public void AssigningTheCurrentValuesIsNotAChange() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + AppConfig config = store.Load(); + + config.Theme = config.Theme; + config.Sort.Card = config.Sort.Card; + config.Confirmations.DeleteTag = config.Confirmations.DeleteTag; + + Assert.IsFalse(store.SaveIfDirty(config)); Assert.IsFalse(File.Exists(path)); } + + [TestMethod] + public void NestedSettingChangeMarksTheConfigurationDirty() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + AppConfig config = store.Load(); + + config.Confirmations.DiscardEdit = false; + + Assert.IsTrue(config.IsDirty); + Assert.IsTrue(store.SaveIfDirty(config)); + Assert.IsFalse(config.IsDirty); + Assert.IsFalse(new AppConfigStore(path).Load().Confirmations.DiscardEdit); + } + + [TestMethod] + public void LoadedFileIsNotDirty() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, """{ "theme": "dark", "confirmations": { "delete-card": false } }"""); + + Assert.IsFalse(new AppConfigStore(path).Load().IsDirty); + } } diff --git a/YKanBan/Services/AppServices.cs b/YKanBan/Services/AppServices.cs index 3e719e8..fa994ab 100644 --- a/YKanBan/Services/AppServices.cs +++ b/YKanBan/Services/AppServices.cs @@ -82,7 +82,7 @@ public sealed class AppServices : IDisposable { try { - ConfigStore.SaveIfChanged(Config); + ConfigStore.SaveIfDirty(Config); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs index ec52fc4..71aa342 100644 --- a/YKanBan/Storage/AppData/AppConfig.cs +++ b/YKanBan/Storage/AppData/AppConfig.cs @@ -62,65 +62,101 @@ public enum ThemeOption FollowSystem, } +/// +/// Base of the app.json sections: records whether any setting was changed +/// since the last load or save (the dirty flag). Assigning the value a setting +/// already holds is not a change; changing a setting and changing it back is. +/// +public abstract class ConfigSection +{ + private bool _changed; + + /// + /// Gets whether a setting of this section, or of a nested section, was changed. + /// + [JsonIgnore] + public virtual bool IsDirty => _changed; + + /// + /// Clears the dirty flag of this section and its nested sections. + /// + public virtual void AcceptChanges() => _changed = false; + + /// + /// Stores a setting and marks the section dirty when the value differs. + /// + /// The setting type. + /// The backing field. + /// The new value. + protected void Set(ref T field, T value) + { + if (!EqualityComparer.Default.Equals(field, value)) + { + field = value; + _changed = true; + } + } +} + /// /// Card ordering settings. The chosen order is global, not per workspace. /// -public sealed class SortSettings +public sealed class SortSettings : ConfigSection { /// /// Gets or sets the order used by the board. /// [JsonProperty("card")] - public CardSortOption Card { get; set; } = CardSortOption.Id; + public CardSortOption Card { get; set => Set(ref field, value); } = CardSortOption.Id; } /// /// Board column width settings, applied globally. /// -public sealed class ColumnWidthSettings +public sealed class ColumnWidthSettings : ConfigSection { /// /// Gets or sets the active width preset. /// [JsonProperty("preset")] - public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard; + public ColumnWidthPreset Preset { get; set => Set(ref field, value); } = ColumnWidthPreset.Standard; /// /// Gets or sets the manual width in pixels, used when /// is (range 160–720). /// [JsonProperty("custom-pixels")] - public int CustomPixels { get; set; } = 320; + public int CustomPixels { get; set => Set(ref field, value); } = 320; } /// /// Confirmation-dialog toggles for the destructive actions. /// -public sealed class ConfirmationSettings +public sealed class ConfirmationSettings : ConfigSection { /// /// Gets or sets whether deleting a card asks for confirmation. /// [JsonProperty("delete-card")] - public bool DeleteCard { get; set; } = true; + public bool DeleteCard { get; set => Set(ref field, value); } = true; /// /// Gets or sets whether deleting a column asks for confirmation. /// [JsonProperty("delete-column")] - public bool DeleteColumn { get; set; } = true; + public bool DeleteColumn { get; set => Set(ref field, value); } = true; /// /// Gets or sets whether deleting a tag asks for confirmation. /// [JsonProperty("delete-tag")] - public bool DeleteTag { get; set; } = true; + public bool DeleteTag { get; set => Set(ref field, value); } = true; /// /// Gets or sets whether discarding card edits asks for confirmation. /// [JsonProperty("discard-edit")] - public bool DiscardEdit { get; set; } = true; + public bool DiscardEdit { get; set => Set(ref field, value); } = true; } /// @@ -129,7 +165,7 @@ public sealed class ConfirmationSettings /// and ). /// The version field is the migration hook for future format changes. /// -public sealed class AppConfig +public sealed class AppConfig : ConfigSection { /// /// Current configuration format version. @@ -165,35 +201,47 @@ public sealed class AppConfig /// Gets or sets the configuration format version of this file. /// [JsonProperty("version")] - public int Version { get; set; } = CurrentFormatVersion; + public int Version { get; set => Set(ref field, value); } = CurrentFormatVersion; /// /// Gets or sets the UI language code ("en" default or "zh-Hans"). /// [JsonProperty("language")] - public string Language { get; set; } = DefaultLanguage; + public string Language { get; set => Set(ref field, value); } = DefaultLanguage; /// /// Gets or sets the UI theme selection (default: follow the system). /// [JsonProperty("theme")] - public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem; + public ThemeOption Theme { get; set => Set(ref field, value); } = ThemeOption.FollowSystem; /// /// Gets or sets the card ordering settings. /// [JsonProperty("sort")] - public SortSettings Sort { get; set; } = new(); + public SortSettings Sort { get; set => Set(ref field, value); } = new(); /// /// Gets or sets the board column width settings. /// [JsonProperty("column-width")] - public ColumnWidthSettings ColumnWidth { get; set; } = new(); + public ColumnWidthSettings ColumnWidth { get; set => Set(ref field, value); } = new(); /// /// Gets or sets the confirmation-dialog settings. /// [JsonProperty("confirmations")] - public ConfirmationSettings Confirmations { get; set; } = new(); + public ConfirmationSettings Confirmations { get; set => Set(ref field, value); } = new(); + + /// + public override bool IsDirty => base.IsDirty || Sort.IsDirty || ColumnWidth.IsDirty || Confirmations.IsDirty; + + /// + public override void AcceptChanges() + { + base.AcceptChanges(); + Sort.AcceptChanges(); + ColumnWidth.AcceptChanges(); + Confirmations.AcceptChanges(); + } } diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs index 66b0a2f..5cf8446 100644 --- a/YKanBan/Storage/AppData/AppConfigStore.cs +++ b/YKanBan/Storage/AppData/AppConfigStore.cs @@ -10,8 +10,8 @@ namespace YKanBan.Storage.AppData; /// then defaults are used; an individual invalid field (unknown enum value or /// language, out-of-range width, wrong type) falls back to its own default while /// the other fields are kept; a file that exists but cannot be read (I/O or -/// permission error) also yields defaults. Saving happens only when the configuration differs -/// from what was loaded, and writes the file directly (no atomic replace) — +/// permission error) also yields defaults. Saving happens only when this session +/// changed a setting (), and writes the file directly (no atomic replace) — /// losing the last session's settings to a crash is an accepted trade-off. /// public sealed class AppConfigStore @@ -36,9 +36,6 @@ public sealed class AppConfigStore Error = (_, args) => args.ErrorContext.Handled = true, }); - /// The serialized form of the configuration as last loaded or saved. - private string _persistedSnapshot = Serialize(new AppConfig()); - /// /// Initializes a store bound to a specific app.json path. /// @@ -51,8 +48,8 @@ public sealed class AppConfigStore public string FilePath { get; } /// - /// Loads the configuration and records it as the persisted baseline for - /// . A missing file yields defaults; a file that + /// Loads the configuration with a clear dirty flag, the baseline for + /// . A missing file yields defaults; a file that /// is not a JSON object is backed up to app.json.bak (best effort) and then /// defaults are used; invalid fields fall back to their defaults /// individually; a file that cannot be read yields defaults. Never throws for @@ -63,39 +60,37 @@ public sealed class AppConfigStore { AppConfig config = Read(); - // The baseline is the effective configuration, so field fallbacks alone never trigger a write. - _persistedSnapshot = Serialize(config); + // Deserializing and field fallbacks go through the setters; neither is a change by this session. + config.AcceptChanges(); return config; } /// - /// Writes the configuration only when it differs from the loaded or last - /// saved one, i.e. when this session changed a setting. + /// Writes the configuration only when a setting was changed since it was + /// loaded or last saved, even if it was later changed back. /// /// The live configuration. /// when the file was written. - public bool SaveIfChanged(AppConfig config) + public bool SaveIfDirty(AppConfig config) { - string json = Serialize(config); - if (json == _persistedSnapshot) + if (!config.IsDirty) { return false; } - Write(json); - _persistedSnapshot = json; + Save(config); return true; } /// - /// Writes the configuration directly to disk (no atomic replace), unconditionally. + /// Writes the configuration directly to disk (no atomic replace), + /// unconditionally, and clears its dirty flag. /// /// The configuration to serialize. public void Save(AppConfig config) { - string json = Serialize(config); - Write(json); - _persistedSnapshot = json; + Write(Serialize(config)); + config.AcceptChanges(); } /// diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 153e717..e9868ed 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -41,6 +41,7 @@ - [ ] 同时打开两个实例(不同工作区)。实例 1 改排序,实例 2 改列宽;先关实例 1、后关实例 2 → `app.json` 为实例 2 的完整设置(后退出者覆盖,实例 1 的排序改动丢失——已接受) - [ ] 打开两个实例,只在实例 1 改设置;先关实例 1,再关**未改过设置**的实例 2 → `app.json` 保持实例 1 写入的内容(未改设置的实例不写盘) +- [ ] 删除 `app.json`,打开设置、不改任何项直接确认后退出 → `app.json` 不被创建;改一项再改回原值后退出 → `app.json` 被写出(脏标记按"改动过"判定) - [ ] 处于错误页(例如参数错误页)的实例退出时不写 `app.json`(可先删除 `app.json`,退出后确认文件未被创建) - [ ] `app.json` 写成非法 JSON → 启动正常、使用默认值,生成 `app.json.bak`;改一次设置退出后 `app.json` 恢复为合法内容 - [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak` diff --git a/docs/PLAN.md b/docs/PLAN.md index b2839bd..49fb768 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,7 +16,7 @@ - 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录) - 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8) - 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7) -- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4) +- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘)(§4.3) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) ## 1. 项目概述 @@ -137,7 +137,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE - 强类型根对象 `AppConfig` 整体序列化,根含 `"version": 1` 迁移钩子 - 字段:语言(`Language`:`"en"` 默认 / `"zh-Hans"`)、主题(`Theme`:`"follow-system"` 默认 / `"light"` / `"dark"`)、卡片排序选择(`"id"` 默认 / `"updated-at"` / `"title"`)、列宽、四类确认开关;枚举值序列化为 kebab-case 字符串 -- 写入时机:**应用退出时,仅当本实例在会话内修改过任一设置(脏标记)才整体写盘**(直接写,不做原子替换);错误页状态的实例、未改过设置的实例退出时不写。多实例均改过设置时后退出者覆盖(已接受);崩溃/断电丢失当次会话的设置变更(已接受:配置低价值) +- 写入时机:**应用退出时,仅当本实例在会话内修改过任一设置(脏标记)才整体写盘**(直接写,不做原子替换);脏标记按"是否改动过"判定:设置项被赋予与当前不同的值即置脏,改后又改回原值仍为脏;赋予相同值(如设置模态未改任何项直接确认)不置脏;读取时的字段回退不置脏;错误页状态的实例、未改过设置的实例退出时不写。多实例均改过设置时后退出者覆盖(已接受);崩溃/断电丢失当次会话的设置变更(已接受:配置低价值) - 读取: - 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建) - JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动