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`(覆盖旧备份)→ 以默认值启动