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.
This commit is contained in:
doyaGu committed 2026-10-03 22:44:11 -04:00
1 parent ce6d9d2f83
commit e7076a76c2
6 files changed
+132 -46

No files matched your search

@@ -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);
}
}
+1 -1
View File
@@ -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)
{
+65 -17
View File
@@ -62,65 +62,101 @@ public enum ThemeOption
FollowSystem,
}
/// <summary>
/// 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.
/// </summary>
public abstract class ConfigSection
{
private bool _changed;
/// <summary>
/// Gets whether a setting of this section, or of a nested section, was changed.
/// </summary>
[JsonIgnore]
public virtual bool IsDirty => _changed;
/// <summary>
/// Clears the dirty flag of this section and its nested sections.
/// </summary>
public virtual void AcceptChanges() => _changed = false;
/// <summary>
/// Stores a setting and marks the section dirty when the value differs.
/// </summary>
/// <typeparam name="T">The setting type.</typeparam>
/// <param name="field">The backing field.</param>
/// <param name="value">The new value.</param>
protected void Set<T>(ref T field, T value)
{
if (!EqualityComparer<T>.Default.Equals(field, value))
{
field = value;
_changed = true;
}
}
}
/// <summary>
/// Card ordering settings. The chosen order is global, not per workspace.
/// </summary>
public sealed class SortSettings
public sealed class SortSettings : ConfigSection
{
/// <summary>
/// Gets or sets the order used by the board.
/// </summary>
[JsonProperty("card")]
public CardSortOption Card { get; set; } = CardSortOption.Id;
public CardSortOption Card { get; set => Set(ref field, value); } = CardSortOption.Id;
}
/// <summary>
/// Board column width settings, applied globally.
/// </summary>
public sealed class ColumnWidthSettings
public sealed class ColumnWidthSettings : ConfigSection
{
/// <summary>
/// Gets or sets the active width preset.
/// </summary>
[JsonProperty("preset")]
public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard;
public ColumnWidthPreset Preset { get; set => Set(ref field, value); } = ColumnWidthPreset.Standard;
/// <summary>
/// Gets or sets the manual width in pixels, used when <see cref="Preset"/>
/// is <see cref="ColumnWidthPreset.Custom"/> (range 160–720).
/// </summary>
[JsonProperty("custom-pixels")]
public int CustomPixels { get; set; } = 320;
public int CustomPixels { get; set => Set(ref field, value); } = 320;
}
/// <summary>
/// Confirmation-dialog toggles for the destructive actions.
/// </summary>
public sealed class ConfirmationSettings
public sealed class ConfirmationSettings : ConfigSection
{
/// <summary>
/// Gets or sets whether deleting a card asks for confirmation.
/// </summary>
[JsonProperty("delete-card")]
public bool DeleteCard { get; set; } = true;
public bool DeleteCard { get; set => Set(ref field, value); } = true;
/// <summary>
/// Gets or sets whether deleting a column asks for confirmation.
/// </summary>
[JsonProperty("delete-column")]
public bool DeleteColumn { get; set; } = true;
public bool DeleteColumn { get; set => Set(ref field, value); } = true;
/// <summary>
/// Gets or sets whether deleting a tag asks for confirmation.
/// </summary>
[JsonProperty("delete-tag")]
public bool DeleteTag { get; set; } = true;
public bool DeleteTag { get; set => Set(ref field, value); } = true;
/// <summary>
/// Gets or sets whether discarding card edits asks for confirmation.
/// </summary>
[JsonProperty("discard-edit")]
public bool DiscardEdit { get; set; } = true;
public bool DiscardEdit { get; set => Set(ref field, value); } = true;
}
/// <summary>
@@ -129,7 +165,7 @@ public sealed class ConfirmationSettings
/// <see cref="JsonPropertyAttribute"/> and <see cref="EnumMemberAttribute"/>).
/// The <c>version</c> field is the migration hook for future format changes.
/// </summary>
public sealed class AppConfig
public sealed class AppConfig : ConfigSection
{
/// <summary>
/// Current configuration format version.
@@ -165,35 +201,47 @@ public sealed class AppConfig
/// Gets or sets the configuration format version of this file.
/// </summary>
[JsonProperty("version")]
public int Version { get; set; } = CurrentFormatVersion;
public int Version { get; set => Set(ref field, value); } = CurrentFormatVersion;
/// <summary>
/// Gets or sets the UI language code ("en" default or "zh-Hans").
/// </summary>
[JsonProperty("language")]
public string Language { get; set; } = DefaultLanguage;
public string Language { get; set => Set(ref field, value); } = DefaultLanguage;
/// <summary>
/// Gets or sets the UI theme selection (default: follow the system).
/// </summary>
[JsonProperty("theme")]
public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
public ThemeOption Theme { get; set => Set(ref field, value); } = ThemeOption.FollowSystem;
/// <summary>
/// Gets or sets the card ordering settings.
/// </summary>
[JsonProperty("sort")]
public SortSettings Sort { get; set; } = new();
public SortSettings Sort { get; set => Set(ref field, value); } = new();
/// <summary>
/// Gets or sets the board column width settings.
/// </summary>
[JsonProperty("column-width")]
public ColumnWidthSettings ColumnWidth { get; set; } = new();
public ColumnWidthSettings ColumnWidth { get; set => Set(ref field, value); } = new();
/// <summary>
/// Gets or sets the confirmation-dialog settings.
/// </summary>
[JsonProperty("confirmations")]
public ConfirmationSettings Confirmations { get; set; } = new();
public ConfirmationSettings Confirmations { get; set => Set(ref field, value); } = new();
/// <inheritdoc/>
public override bool IsDirty => base.IsDirty || Sort.IsDirty || ColumnWidth.IsDirty || Confirmations.IsDirty;
/// <inheritdoc/>
public override void AcceptChanges()
{
base.AcceptChanges();
Sort.AcceptChanges();
ColumnWidth.AcceptChanges();
Confirmations.AcceptChanges();
}
}
+15 -20
View File
@@ -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 (<see cref="ConfigSection.IsDirty"/>), and writes the file directly (no atomic replace) —
/// losing the last session's settings to a crash is an accepted trade-off.
/// </summary>
public sealed class AppConfigStore
@@ -36,9 +36,6 @@ public sealed class AppConfigStore
Error = (_, args) => args.ErrorContext.Handled = true,
});
/// <summary>The serialized form of the configuration as last loaded or saved.</summary>
private string _persistedSnapshot = Serialize(new AppConfig());
/// <summary>
/// Initializes a store bound to a specific app.json path.
/// </summary>
@@ -51,8 +48,8 @@ public sealed class AppConfigStore
public string FilePath { get; }
/// <summary>
/// Loads the configuration and records it as the persisted baseline for
/// <see cref="SaveIfChanged"/>. A missing file yields defaults; a file that
/// Loads the configuration with a clear dirty flag, the baseline for
/// <see cref="SaveIfDirty"/>. 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="config">The live configuration.</param>
/// <returns><see langword="true"/> when the file was written.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="config">The configuration to serialize.</param>
public void Save(AppConfig config)
{
string json = Serialize(config);
Write(json);
_persistedSnapshot = json;
Write(Serialize(config));
config.AcceptChanges();
}
/// <summary>
+1
View File
@@ -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`
+2 -2
View File
@@ -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`(覆盖旧备份)→ 以默认值启动