From a65fb27948e06cc88f1e1bcd7301e8f1b28fd3d5 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 07:20:21 +0000 Subject: [PATCH 1/2] Add Trash UI for restoring and purging soft-deleted snips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-deleted snips (IsTrash) previously vanished from every view with no way to recover or permanently remove them. Add a "Trash" pane-footer entry that lists trashed snips across all CLIs, each with Restore (clears the trash flag, returning the snip to its CLI) and Delete permanently (removes it from the store after confirmation). Trash actions don't add or remove a CLI, so they persist and rebuild the Trash list in place via SaveAndRefreshTrashAsync rather than the full SaveAndRefreshAsync — keeping the user on the Trash view. The CLI switcher and tag list refresh lazily on next CLI selection, which already reads the updated document. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 + .../Views/ShellContentTemplateSelector.cs | 3 + src/Snipdeck.App/Views/ShellPage.xaml | 108 ++++++++++++++++-- src/Snipdeck.App/Views/ShellPage.xaml.cs | 5 + .../ViewModels/ShellViewModel.cs | 52 +++++++++ .../ViewModels/TrashViewModel.cs | 33 ++++++ .../ViewModels/ShellViewModelCommandsTests.cs | 68 +++++++++++ 7 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 src/Snipdeck.Core/ViewModels/TrashViewModel.cs 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,22 @@ private void ApplyShellContent() } } + private TrashViewModel BuildTrashViewModel() + { + var trashed = _document.Snips.Where(s => s.IsTrash); + return new TrashViewModel(trashed); + } + + // Trash actions (restore / delete-forever) don't add or remove a CLI, so + // the CLI switcher and tag list don't need rebuilding here — those are + // refreshed lazily the next time a CLI is selected. We just persist and + // rebuild the Trash list in place so the user stays on the Trash view. + private async Task SaveAndRefreshTrashAsync() + { + await _store.SaveAsync(_document).ConfigureAwait(true); + 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..4192721 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -305,6 +305,74 @@ 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 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() { From 6d7e8d17be9750a890da4f279628d023bebb59cc Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 07:27:45 +0000 Subject: [PATCH 2/2] Refresh pane tags after trash restore/purge SaveAndRefreshTrashAsync persisted the document and rebuilt the Trash list but left the pane tag list for the currently-selected CLI stale: a restore could surface a tag no visible snip had, and a purge could orphan one, with the stale list staying on screen until the user switched CLIs and back. Rebuild the tags in place (suppressing the content swap a tag change would trigger) so the pane stays accurate while the user remains on the Trash view. Found by codex review. Co-Authored-By: Claude Opus 4.8 --- .../ViewModels/ShellViewModel.cs | 24 ++++++++++--- .../ViewModels/ShellViewModelCommandsTests.cs | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs index 38ef6bb..20674bb 100644 --- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -430,13 +430,29 @@ private TrashViewModel BuildTrashViewModel() return new TrashViewModel(trashed); } - // Trash actions (restore / delete-forever) don't add or remove a CLI, so - // the CLI switcher and tag list don't need rebuilding here — those are - // refreshed lazily the next time a CLI is selected. We just persist and - // rebuild the Trash list in place so the user stays on the Trash view. + // 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(); } diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs index 4192721..545a451 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -346,6 +346,40 @@ public async Task RestoreSnip_clears_trash_flag_saves_and_drops_it_from_the_tras 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() {