fix(ui): Ordner-Toggle in der Sidebar schluckt schnelle Klicks
ToggleFolderAsync war ein async [RelayCommand]; AsyncRelayCommand sperrt sich waehrend des DB-Writes selbst, schnelle Klicks gingen verloren (Befund F1, docs/test-session-2026-09-16.md). Variante A: Toggle ist jetzt synchron (reiner UI-State), Persistierung fire-and-forget mit einem CancellationTokenSource pro Ordner, damit ein zweiter Klick vor dem ersten Schreibvorgang diesen abbricht - die DB landet immer beim zuletzt sichtbaren Zustand, nie bei einem veralteten. Gleiches Muster wie SaveTitleAsync/SaveDescriptionAsync in DetailsIslandViewModel. Gegenprueft: RemoveFromFolderAsync/CreateListAsync haben denselben async-RelayCommand-Schnitt, sind aber Einzelklick-Aktionen (Kontextmenue/ Button), kein Rapid-Click-Ziel wie der Ordner-Toggle - bewusst nicht umgebaut. Die Modal-oeffnenden Commands sind unkritisch, ein Modal blockiert ohnehin.
This commit is contained in:
@@ -622,16 +622,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
await MoveToFolderAsync(row, null);
|
await MoveToFolderAsync(row, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
|
||||||
|
|
||||||
|
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
|
||||||
|
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
|
||||||
|
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
|
||||||
|
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
|
||||||
|
// stale one racing ahead of it.
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
|
private void ToggleFolder(ListFolderNavItemViewModel? folder)
|
||||||
{
|
{
|
||||||
if (folder is null) return;
|
if (folder is null) return;
|
||||||
folder.IsCollapsed = !folder.IsCollapsed;
|
folder.IsCollapsed = !folder.IsCollapsed;
|
||||||
|
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_folderCollapseCts[folder.Id] = cts;
|
||||||
|
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
|
ct.ThrowIfCancellationRequested();
|
||||||
|
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// The fold itself already happened in the UI; only its persistence failed.
|
// The fold itself already happened in the UI; only its persistence failed.
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ public class ListsIslandFolderTests : IDisposable
|
|||||||
var folder = vm.Folders[0];
|
var folder = vm.Folders[0];
|
||||||
Assert.False(folder.IsCollapsed);
|
Assert.False(folder.IsCollapsed);
|
||||||
|
|
||||||
await vm.ToggleFolderCommand.ExecuteAsync(folder);
|
vm.ToggleFolderCommand.Execute(folder);
|
||||||
|
await Task.Delay(100);
|
||||||
|
|
||||||
Assert.True(folder.IsCollapsed);
|
Assert.True(folder.IsCollapsed);
|
||||||
await using var db = NewContext();
|
await using var db = NewContext();
|
||||||
@@ -275,6 +276,31 @@ public class ListsIslandFolderTests : IDisposable
|
|||||||
Assert.True(reloaded.Folders[0].IsCollapsed);
|
Assert.True(reloaded.Folders[0].IsCollapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToggleFolder_rapid_double_click_both_toggles_land()
|
||||||
|
{
|
||||||
|
// Bug F1 (docs/test-session-2026-09-16.md): the command used to be an async
|
||||||
|
// [RelayCommand], so AsyncRelayCommand's own CanExecute lock swallowed the second click
|
||||||
|
// while the first one's DB write was still in flight. Firing two toggles back-to-back
|
||||||
|
// (no await between them) reproduces exactly that window.
|
||||||
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||||
|
|
||||||
|
var vm = NewVm();
|
||||||
|
await vm.LoadAsync();
|
||||||
|
var folder = vm.Folders[0];
|
||||||
|
Assert.False(folder.IsCollapsed);
|
||||||
|
|
||||||
|
vm.ToggleFolderCommand.Execute(folder);
|
||||||
|
vm.ToggleFolderCommand.Execute(folder);
|
||||||
|
await Task.Delay(100);
|
||||||
|
|
||||||
|
// Two flips land back on the original (visible) state...
|
||||||
|
Assert.False(folder.IsCollapsed);
|
||||||
|
// ...and the DB ends up agreeing with it, not with the stale mid-flight value.
|
||||||
|
await using var db = NewContext();
|
||||||
|
Assert.False((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
|
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user