diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 479b7604..22dbf370 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -622,16 +622,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable await MoveToFolderAsync(row, null); } + private readonly Dictionary _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] - private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder) + private void ToggleFolder(ListFolderNavItemViewModel? folder) { if (folder is null) return; 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 { 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) { // The fold itself already happened in the UI; only its persistence failed. diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs index b243c371..87bb24a5 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs @@ -263,7 +263,8 @@ public class ListsIslandFolderTests : IDisposable var folder = vm.Folders[0]; Assert.False(folder.IsCollapsed); - await vm.ToggleFolderCommand.ExecuteAsync(folder); + vm.ToggleFolderCommand.Execute(folder); + await Task.Delay(100); Assert.True(folder.IsCollapsed); await using var db = NewContext(); @@ -275,6 +276,31 @@ public class ListsIslandFolderTests : IDisposable 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] public async Task RemoveFromFolder_command_is_the_context_menu_route_out() {