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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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`(覆盖旧备份)→ 以默认值启动
|
||||
|
||||
Reference in new issue
Block a user