diff --git a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs
index 8acc66e..66ae428 100644
--- a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs
+++ b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs
@@ -204,6 +204,66 @@ public class BoardViewModelTests
Assert.AreEqual(card.Id, board.Columns[0].Cards.Single().Card.Id);
}
+ [TestMethod]
+ public async Task EditorThatWroteNothingLeavesTheColumnsAlone()
+ {
+ using var fixture = new Fixture();
+ ColumnModel column = fixture.Repository.AddColumn("A", "");
+ fixture.Repository.AddCard(column.Id, "t", "", []);
+ BoardViewModel board = fixture.CreateBoard();
+ ColumnViewModel shown = board.Columns.Single();
+ int writes = 0;
+ board.DataWritten += (_, _) => writes++;
+
+ // Cancel a new card, confirm an unchanged existing card, confirm an empty new card.
+ fixture.Dialogs.Script = dialog => dialog is CardEditorDialogViewModel editor
+ ? editor.RequestCancelAsync()
+ : Task.CompletedTask;
+ await shown.AddCardCommand.ExecuteAsync(null);
+ fixture.Dialogs.Script = dialog =>
+ {
+ (dialog as CardEditorDialogViewModel)?.ConfirmCommand.Execute(null);
+ return Task.CompletedTask;
+ };
+ await shown.Cards.Single().EditCommand.ExecuteAsync(null);
+ await shown.AddCardCommand.ExecuteAsync(null);
+
+ Assert.AreSame(shown, board.Columns.Single());
+ Assert.AreEqual(0, writes);
+ }
+
+ [TestMethod]
+ public async Task TagCreatedInACancelledEditRefreshesTheBoard()
+ {
+ using var fixture = new Fixture();
+ fixture.Repository.AddColumn("A", "");
+ BoardViewModel board = fixture.CreateBoard();
+ ColumnViewModel shown = board.Columns.Single();
+ int writes = 0;
+ board.DataWritten += (_, _) => writes++;
+ fixture.Dialogs.Script = async dialog =>
+ {
+ switch (dialog)
+ {
+ case CardEditorDialogViewModel editor:
+ await editor.AddTagCommand.ExecuteAsync(null);
+ editor.Tags.Clear();
+ await editor.RequestCancelAsync();
+ break;
+ case TagPickerDialogViewModel picker:
+ picker.NewName = "fresh";
+ picker.NewColor = "#123456";
+ picker.CreateCommand.Execute(null);
+ break;
+ }
+ };
+
+ await shown.AddCardCommand.ExecuteAsync(null);
+
+ Assert.AreNotSame(shown, board.Columns.Single());
+ Assert.AreEqual(1, writes);
+ }
+
[TestMethod]
public void UnchangedEditConfirmationWritesNothing()
{
diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs
index 2331715..d728a8f 100644
--- a/YKanBan/ViewModels/Board/BoardViewModel.cs
+++ b/YKanBan/ViewModels/Board/BoardViewModel.cs
@@ -274,9 +274,7 @@ public sealed partial class BoardViewModel : ViewModelBase
var editor = CardEditorDialogViewModel.ForNewCard(
_repository, _dialogs, columnId, _config.Confirmations.DiscardEdit);
await _dialogs.ShowAsync(editor);
-
- // Refresh even after a cancel: the nested picker may have created tags.
- await RefreshAfterWriteAsync();
+ await RefreshAfterEditorAsync(editor);
}
///
@@ -306,9 +304,20 @@ public sealed partial class BoardViewModel : ViewModelBase
var editor = CardEditorDialogViewModel.ForExistingCard(
_repository, _dialogs, card, _config.Confirmations.DiscardEdit);
await _dialogs.ShowAsync(editor);
- await RefreshAfterWriteAsync();
+ await RefreshAfterEditorAsync(editor);
}
+ ///
+ /// Refreshes after the card editor closes, but only when it wrote something:
+ /// a saved card, or a tag created in the nested picker even if the edit was
+ /// cancelled. An editor that wrote nothing leaves the columns, and so their
+ /// scroll positions, untouched.
+ ///
+ /// The closed editor.
+ /// A task completing after the refresh, if any.
+ private Task RefreshAfterEditorAsync(CardEditorDialogViewModel editor) =>
+ editor.WroteToWorkspace ? RefreshAfterWriteAsync() : Task.CompletedTask;
+
///
/// Deletes a card, asking first when that confirmation is enabled.
///
diff --git a/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs
index 562120b..140f219 100644
--- a/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs
+++ b/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs
@@ -118,6 +118,12 @@ public sealed partial class CardEditorDialogViewModel : DialogViewModelBase
///
public CardModel? SavedCard { get; private set; }
+ ///
+ /// Gets whether this edit session wrote anything: the card on confirmation,
+ /// or a tag created in the nested picker (kept even when the edit is cancelled).
+ ///
+ public bool WroteToWorkspace { get; private set; }
+
///
/// Gets or sets the card title; the empty string means "no title".
///
@@ -169,9 +175,11 @@ public sealed partial class CardEditorDialogViewModel : DialogViewModelBase
return;
}
- if (await _dialogs.ShowAsync(picker) && picker.PickedTag is { } picked)
+ bool picked = await _dialogs.ShowAsync(picker);
+ WroteToWorkspace |= picker.CreatedTag;
+ if (picked && picker.PickedTag is { } tag)
{
- Tags.Add(new TagBadgeViewModel(picked));
+ Tags.Add(new TagBadgeViewModel(tag));
}
}
@@ -202,6 +210,7 @@ public sealed partial class CardEditorDialogViewModel : DialogViewModelBase
SavedCard = _existing is null
? _repository.AddCard(_columnId, CardTitle, Content, tagIds)
: _repository.UpdateCard(_existing.Id, CardTitle, Content, tagIds);
+ WroteToWorkspace = true;
}
catch (Exception exception)
{
diff --git a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs
index f500f75..3e833d5 100644
--- a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs
+++ b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs
@@ -48,6 +48,11 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
///
public TagModel? PickedTag { get; private set; }
+ ///
+ /// Gets whether was newly created, i.e. written to the workspace.
+ ///
+ public bool CreatedTag { get; private set; }
+
///
/// Gets the available tags matching .
///
@@ -127,6 +132,7 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
try
{
PickedTag = _repository.AddTag(NewName, color, NewDescription);
+ CreatedTag = true;
}
catch (Exception exception)
{
diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
index 7bb0fba..895bcc7 100644
--- a/docs/ACCEPTANCE.md
+++ b/docs/ACCEPTANCE.md
@@ -75,6 +75,7 @@
- [ ] 列宽:窄 240 / 标准 320 / 宽 480 / 手动 160–720;全局生效,重启保持
- [ ] 排序:id 升序 / 最后修改时间降序 / 标题升序(空标题在前);只影响卡片,列始终 id 序;重启保持
- [ ] 大数据量:用脚本向一列灌入约 10000 张卡片,滚动流畅、打开工作区无明显卡顿(虚拟化)
+- [ ] 长列滚动到中部后打开某张卡片,不改动就取消或确认 → 各列滚动位置不变(未写库不刷新看板)
- [ ] 所有模态在父窗口居中;使用系统原生标题栏
- [ ] 打开带输入框的模态时,第一个输入框自动获得焦点并全选