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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SettingsTemplate="{StaticResource SettingsContentTemplate}"
+ TrashTemplate="{StaticResource TrashContentTemplate}" />
-
+
+
+
+
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()
{