fix(ui): don't leave an orphaned list behind on cancel
"New list" pre-creates the DB row before opening List Settings. Cancelling or closing that modal left an untitled empty list behind; CreateListAsync now rolls the row back unless the modal reports Saved. Also moves the new-list/import-repo buttons up next to the "My Lists" label instead of a row below the list, and puts Notes above Important in the smart-list nav order.
This commit is contained in:
@@ -282,9 +282,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
var smart = new[]
|
var smart = new[]
|
||||||
{
|
{
|
||||||
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
|
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: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 = "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: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: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" },
|
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<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
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);
|
else await RefreshRowAsync(item.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// True after the list was deleted, so the caller reloads the list nav instead of refreshing the row.
|
||||||
public bool Deleted { get; private set; }
|
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.
|
// Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error.
|
||||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
public Func<string, Task>? ShowErrorAsync { get; set; }
|
public Func<string, Task>? ShowErrorAsync { get; set; }
|
||||||
@@ -149,6 +153,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
|
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
|
||||||
ticketProjectId);
|
ticketProjectId);
|
||||||
|
|
||||||
|
Saved = true;
|
||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,21 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<!-- MY LISTS section -->
|
<!-- MY LISTS section — add-list + repo-import sit next to the label -->
|
||||||
<TextBlock Classes="section-label" Text="{loc:Tr lists.myListsLabel}" Margin="10,10,10,4"/>
|
<Grid ColumnDefinitions="*,Auto,Auto" Margin="10,10,6,4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Grid.Column="0" Classes="section-label"
|
||||||
|
Text="{loc:Tr lists.myListsLabel}" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Classes="icon-btn"
|
||||||
|
Command="{Binding CreateListCommand}"
|
||||||
|
ToolTip.Tip="{loc:Tr lists.newList}">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Plus}" Width="13" Height="13"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Classes="icon-btn"
|
||||||
|
Command="{Binding OpenRepoImportCommand}"
|
||||||
|
ToolTip.Tip="{loc:Tr lists.addReposTip}">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
<ItemsControl ItemsSource="{Binding UserLists}">
|
<ItemsControl ItemsSource="{Binding UserLists}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:ListNavItemViewModel">
|
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||||
@@ -201,26 +214,6 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<!-- New list + import row -->
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
|
||||||
<Button Grid.Column="0" Classes="new-list-btn"
|
|
||||||
Command="{Binding CreateListCommand}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Plus}"
|
|
||||||
Width="13" Height="13"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Classes="body"
|
|
||||||
Text="{loc:Tr lists.newList}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
|
|
||||||
Command="{Binding OpenRepoImportCommand}"
|
|
||||||
ToolTip.Tip="{loc:Tr lists.addReposTip}">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -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<ClaudeDoDbContext>()
|
||||||
|
.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<IReadOnlyList<string>?> 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<bool> 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<ListSettingsModalViewModel> create) : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) =>
|
||||||
|
serviceType == typeof(ListSettingsModalViewModel) ? create() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ListsIslandViewModel> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user