diff --git a/CHANGELOG.md b/CHANGELOG.md index 57345c7..091d3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Trash.** A "Trash" entry in the pane footer lists soft-deleted snips from + across every CLI. Each can be **Restored** (returned to its CLI) or **Deleted + permanently** (after confirmation). Previously, deleting a snip moved it to + trash but it then vanished with no way to recover or purge it. - **Configurable backup retention.** Choose how many timestamped store backups to keep (default 20) from Settings → "Backups to keep". The count is honoured on the next write-triggered backup, with no restart required. diff --git a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs index 4a861b1..5c37434 100644 --- a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs +++ b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs @@ -17,6 +17,8 @@ public sealed partial class ShellContentTemplateSelector : DataTemplateSelector public DataTemplate? SettingsTemplate { get; set; } + public DataTemplate? TrashTemplate { get; set; } + protected override DataTemplate? SelectTemplateCore(object item) { return item switch @@ -24,6 +26,7 @@ public sealed partial class ShellContentTemplateSelector : DataTemplateSelector HomeViewModel => HomeTemplate, CliViewModel => CliTemplate, SettingsViewModel => SettingsTemplate, + TrashViewModel => TrashTemplate, _ => null, }; } diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml index 196ece1..ec1b82d 100644 --- a/src/Snipdeck.App/Views/ShellPage.xaml +++ b/src/Snipdeck.App/Views/ShellPage.xaml @@ -227,10 +227,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + c.IsHome); @@ -159,6 +164,37 @@ private async Task DeleteSnipAsync(SnipCardViewModel? cardVm) await SaveAndRefreshAsync().ConfigureAwait(true); } + [RelayCommand] + private async Task RestoreSnipAsync(SnipCardViewModel? cardVm) + { + if (cardVm is null) + { + return; + } + cardVm.Snip.IsTrash = false; + await SaveAndRefreshTrashAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task DeleteForeverAsync(SnipCardViewModel? cardVm) + { + if (cardVm is null) + { + return; + } + var confirmed = await _interactions.ConfirmAsync( + "Delete permanently", + $"Permanently delete “{cardVm.Snip.Title}”? This can't be undone.", + "Delete", + "Cancel").ConfigureAwait(true); + if (!confirmed) + { + return; + } + _ = _document.Snips.RemoveAll(s => s.Id == cardVm.Snip.Id); + await SaveAndRefreshTrashAsync().ConfigureAwait(true); + } + [RelayCommand] private async Task ToggleFavouriteAsync(SnipCardViewModel? cardVm) { @@ -388,6 +424,38 @@ private void ApplyShellContent() } } + private TrashViewModel BuildTrashViewModel() + { + var trashed = _document.Snips.Where(s => s.IsTrash); + return new TrashViewModel(trashed); + } + + // Trash actions (restore / delete-forever) never add or remove a CLI, so + // the CLI switcher doesn't need rebuilding. But the pane tag list for the + // currently-selected CLI can go stale — a restore can surface a tag no + // visible snip had, and a purge can orphan one — and that list stays on + // screen while the user is on the Trash view. So rebuild the tags in + // place, then refresh the Trash list, all while keeping the user on the + // Trash view (suppressing the shell-content swap that a tag change would + // otherwise trigger). + private async Task SaveAndRefreshTrashAsync() + { + await _store.SaveAsync(_document).ConfigureAwait(true); + + _suppressShellRefresh = true; + try + { + RebuildTags(); + SelectedTag = Tags.Count > 0 ? AllTagsSentinel : null; + } + finally + { + _suppressShellRefresh = false; + } + + CurrentContent = BuildTrashViewModel(); + } + private async Task SaveAndRefreshAsync() { await _store.SaveAsync(_document).ConfigureAwait(true); diff --git a/src/Snipdeck.Core/ViewModels/TrashViewModel.cs b/src/Snipdeck.Core/ViewModels/TrashViewModel.cs new file mode 100644 index 0000000..e7d78c6 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/TrashViewModel.cs @@ -0,0 +1,33 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + /// + /// The Trash content view: the cross-CLI list of soft-deleted snips, each of + /// which can be restored or permanently deleted. Reuses + /// so the card visuals stay consistent with + /// the rest of the shell. + /// + public sealed partial class TrashViewModel : ObservableObject + { + public TrashViewModel(IEnumerable trashedSnips) + { + ArgumentNullException.ThrowIfNull(trashedSnips); + + Snips = new ObservableCollection( + trashedSnips + .OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase) + .Select(s => new SnipCardViewModel(s))); + } + + public ObservableCollection Snips { get; } + + public bool HasSnips => Snips.Count > 0; + + public bool IsEmpty => Snips.Count == 0; + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs index d926771..545a451 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -305,6 +305,108 @@ public async Task DeleteCurrentCli_no_ops_on_home() Assert.Equal(0, store.SaveCount); } + [Fact] + public async Task OpenTrash_shows_only_trashed_snips() + { + Cli cli = null!; + var (vm, _, _, _, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "Active", CommandTemplate = "a" }); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "Binned", CommandTemplate = "b", IsTrash = true }); + }); + + vm.OpenTrash(); + + var trash = Assert.IsType(vm.CurrentContent); + Assert.Single(trash.Snips); + Assert.Equal("Binned", trash.Snips[0].Title); + } + + [Fact] + public async Task RestoreSnip_clears_trash_flag_saves_and_drops_it_from_the_trash_view() + { + Cli cli = null!; + var (vm, store, _, _, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "Binned", CommandTemplate = "b", IsTrash = true }); + }); + + vm.OpenTrash(); + var card = ((TrashViewModel)vm.CurrentContent!).Snips[0]; + + await vm.RestoreSnipCommand.ExecuteAsync(card); + + Assert.False(store.Document.Snips[0].IsTrash); + Assert.Equal(1, store.SaveCount); + var trash = Assert.IsType(vm.CurrentContent); + Assert.Empty(trash.Snips); + } + + [Fact] + public async Task RestoreSnip_refreshes_the_pane_tags_for_the_selected_cli() + { + Cli cli = null!; + var (vm, _, _, _, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "Active", CommandTemplate = "a" }); + // Trashed, and carrying a tag no visible snip has. + d.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Binned", + CommandTemplate = "b", + Tags = ["incident"], + IsTrash = true, + }); + }); + + // Select the CLI: its pane tags should not yet include the trashed snip's tag. + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + Assert.DoesNotContain("incident", vm.Tags); + + // Restore from Trash while that CLI is still the selected one. + vm.OpenTrash(); + var card = ((TrashViewModel)vm.CurrentContent!).Snips[0]; + await vm.RestoreSnipCommand.ExecuteAsync(card); + + // The pane tag list must now reflect the restored snip, and we stay on Trash. + Assert.Contains("incident", vm.Tags); + Assert.IsType(vm.CurrentContent); + } + + [Fact] + public async Task DeleteForever_only_removes_the_snip_when_confirmed() + { + Cli cli = null!; + var (vm, store, _, ix, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "Binned", CommandTemplate = "b", IsTrash = true }); + }); + + vm.OpenTrash(); + var card = ((TrashViewModel)vm.CurrentContent!).Snips[0]; + + ix.NextConfirmResult = false; + await vm.DeleteForeverCommand.ExecuteAsync(card); + Assert.Single(store.Document.Snips); + Assert.Equal(0, store.SaveCount); + + ix.NextConfirmResult = true; + await vm.DeleteForeverCommand.ExecuteAsync(card); + Assert.Empty(store.Document.Snips); + Assert.Equal(1, store.SaveCount); + var trash = Assert.IsType(vm.CurrentContent); + Assert.Empty(trash.Snips); + } + [Fact] public async Task NewCli_adds_the_cli_and_writes_icon_bytes_when_provided() {