diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 27f5b6c6..e6aea290 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -282,9 +282,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var smart = new[] { new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" }, + new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" }, new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" }, new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" }, - new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" }, new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" }, new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" }, new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" }, @@ -410,7 +410,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var vm = _services.GetRequiredService(); await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon); await Dialogs.ShowListSettingsAsync(vm); - if (vm.Deleted) await LoadAsync(); + if (!vm.Saved && !vm.Deleted) + { + // Cancel/close right after "New list" means "never mind" — drop the row we + // pre-created, otherwise an untitled empty list is left behind. + await using var ctx = await _dbFactory.CreateDbContextAsync(); + await new ListRepository(ctx).DeleteAsync(entity.Id); + await LoadAsync(); + } + else if (vm.Deleted) await LoadAsync(); else await RefreshRowAsync(item.Id); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index b860f646..fe8a48bd 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -26,6 +26,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase // True after the list was deleted, so the caller reloads the list nav instead of refreshing the row. public bool Deleted { get; private set; } + // False when the modal closed via Cancel or the window chrome — CreateListAsync uses this to + // roll the pre-created row back instead of leaving an untitled empty list behind. + public bool Saved { get; private set; } + // Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error. public Func>? ConfirmAsync { get; set; } public Func? ShowErrorAsync { get; set; } @@ -149,6 +153,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, ticketProjectId); + Saved = true; CloseAction?.Invoke(); } diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 3055b834..bcba3dda 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -118,8 +118,21 @@ - - + + + + + + @@ -201,26 +214,6 @@ - - - - - - diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs new file mode 100644 index 00000000..65957521 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandCreateListCancelTests.cs @@ -0,0 +1,107 @@ +using ClaudeDo.Data; +using ClaudeDo.Localization; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using ClaudeDo.Ui.ViewModels.Conflicts; +using ClaudeDo.Ui.ViewModels.Islands; +using ClaudeDo.Ui.ViewModels.Modals; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +// "New list" pre-creates the DB row and then opens List Settings on it. Cancelling (or closing) +// that modal used to leave an untitled empty list behind — CreateListAsync rolls the row back +// unless the modal reports Saved (or Deleted, which already handled itself). +public class ListsIslandCreateListCancelTests : IDisposable +{ + private readonly string _dbPath; + + public ListsIslandCreateListCancelTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_newlist_cancel_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) + dir = Path.GetDirectoryName(dir); + Loc.Current = new Localizer( + LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + // Stands in for the modal window: either leaves the VM untouched (cancel / window close) + // or runs its Save command. + private sealed class FakeDialogService(bool save) : IDialogService + { + public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => + save ? vm.SaveCommand.ExecuteAsync(null) : Task.CompletedTask; + + public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException(); + public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException(); + public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException(); + public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException(); + public Task?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException(); + public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException(); + public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException(); + public Task ConfirmAsync(string message) => Task.FromResult(true); + public Task ShowErrorAsync(string message) => Task.CompletedTask; + public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); + } + + private sealed class ListSettingsProvider(Func create) : IServiceProvider + { + public object? GetService(Type serviceType) => + serviceType == typeof(ListSettingsModalViewModel) ? create() : null; + } + + private async Task RunCreateListAsync(bool save) + { + var factory = new TestDbFactory(NewContext); + var worker = new DefaultStub(); + var services = new ListSettingsProvider(() => new ListSettingsModalViewModel(worker, factory)); + var vm = new ListsIslandViewModel(factory, services) { Dialogs = new FakeDialogService(save) }; + await vm.LoadAsync(); + + await vm.CreateListCommand.ExecuteAsync(null); + return vm; + } + + [Fact] + public async Task CancellingListSettings_RemovesThePreCreatedList() + { + var vm = await RunCreateListAsync(save: false); + + await using var db = NewContext(); + Assert.Empty(db.Lists); + Assert.Empty(vm.UserLists); + } + + [Fact] + public async Task SavingListSettings_KeepsTheNewList() + { + var vm = await RunCreateListAsync(save: true); + + await using var db = NewContext(); + Assert.Single(db.Lists); + Assert.Single(vm.UserLists); + } +}