Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/Snipdeck.App/Views/ShellContentTemplateSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ 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
{
HomeViewModel => HomeTemplate,
CliViewModel => CliTemplate,
SettingsViewModel => SettingsTemplate,
TrashViewModel => TrashTemplate,
_ => null,
};
}
Expand Down
108 changes: 96 additions & 12 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,81 @@
</ScrollViewer>
</DataTemplate>

<DataTemplate x:Key="TrashContentTemplate" x:DataType="vm:TrashViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Padding="24" Spacing="16">
<TextBlock Text="Trash"
Style="{ThemeResource TitleTextBlockStyle}" />
<TextBlock Text="Deleted snips are kept here. Restore a snip to put it back in its CLI, or delete it permanently."
Style="{ThemeResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />

<TextBlock Text="Trash is empty."
Style="{ThemeResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="{x:Bind IsEmpty, Converter={StaticResource BoolToVisibility}}" />

<ItemsControl ItemsSource="{x:Bind Snips}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnipCardViewModel">
<Grid Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>

<TextBlock Grid.Row="0"
Text="{x:Bind Title}"
Style="{ThemeResource BodyStrongTextBlockStyle}"
TextTrimming="CharacterEllipsis" />

<Border Grid.Row="1"
Background="{ThemeResource SubtleFillColorSecondaryBrush}"
CornerRadius="4"
Padding="8,6"
Margin="0,8,0,0">
<TextBlock Text="{x:Bind CommandTemplate}"
FontFamily="Cascadia Mono, Consolas, Courier New"
FontSize="12"
TextWrapping="Wrap" />
</Border>

<StackPanel Grid.Row="2"
Orientation="Horizontal"
Spacing="8"
Margin="0,12,0,0">
<Button Content="Restore"
Style="{ThemeResource AccentButtonStyle}"
Command="{Binding ElementName=ShellRoot, Path=ViewModel.RestoreSnipCommand}"
CommandParameter="{x:Bind}" />
<Button Content="Delete permanently"
Command="{Binding ElementName=ShellRoot, Path=ViewModel.DeleteForeverCommand}"
CommandParameter="{x:Bind}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</DataTemplate>

<local:ShellContentTemplateSelector x:Key="ShellContentSelector"
HomeTemplate="{StaticResource HomeContentTemplate}"
CliTemplate="{StaticResource CliContentTemplate}"
SettingsTemplate="{StaticResource SettingsContentTemplate}" />
SettingsTemplate="{StaticResource SettingsContentTemplate}"
TrashTemplate="{StaticResource TrashContentTemplate}" />
</UserControl.Resources>

<NavigationView x:Name="ShellNavigation"
Expand All @@ -256,17 +327,30 @@
</NavigationView.PaneHeader>

<NavigationView.PaneFooter>
<Button x:Name="SettingsButton"
Click="OnSettingsClicked"
HorizontalAlignment="Stretch"
Margin="12,8"
Background="Transparent"
BorderThickness="0">
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon Glyph="&#xE713;" FontSize="16" />
<TextBlock Text="Settings" VerticalAlignment="Center" />
</StackPanel>
</Button>
<StackPanel>
<Button x:Name="TrashButton"
Click="OnTrashClicked"
HorizontalAlignment="Stretch"
Margin="12,8,12,0"
Background="Transparent"
BorderThickness="0">
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon Glyph="&#xE74D;" FontSize="16" />
<TextBlock Text="Trash" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="SettingsButton"
Click="OnSettingsClicked"
HorizontalAlignment="Stretch"
Margin="12,8"
Background="Transparent"
BorderThickness="0">
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon Glyph="&#xE713;" FontSize="16" />
<TextBlock Text="Settings" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</NavigationView.PaneFooter>

<ContentControl Content="{x:Bind ViewModel.CurrentContent, Mode=OneWay}"
Expand Down
5 changes: 5 additions & 0 deletions src/Snipdeck.App/Views/ShellPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ private void OnSettingsClicked(object sender, RoutedEventArgs e)
ViewModel.OpenSettings(settings);
}

private void OnTrashClicked(object sender, RoutedEventArgs e)
{
ViewModel.OpenTrash();
}

private void OnNavigationSelectionChanged(
NavigationView sender,
NavigationViewSelectionChangedEventArgs args)
Expand Down
68 changes: 68 additions & 0 deletions src/Snipdeck.Core/ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ public void OpenSettings(SettingsViewModel settings)
CurrentContent = settings;
}

public void OpenTrash()
{
CurrentContent = BuildTrashViewModel();
}

public void GoHome()
{
SelectedCliChoice = CliChoices.FirstOrDefault(c => c.IsHome);
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions src/Snipdeck.Core/ViewModels/TrashViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Collections.ObjectModel;

using CommunityToolkit.Mvvm.ComponentModel;

using Snipdeck.Core.Models;

namespace Snipdeck.Core.ViewModels
{
/// <summary>
/// The Trash content view: the cross-CLI list of soft-deleted snips, each of
/// which can be restored or permanently deleted. Reuses
/// <see cref="SnipCardViewModel"/> so the card visuals stay consistent with
/// the rest of the shell.
/// </summary>
public sealed partial class TrashViewModel : ObservableObject
{
public TrashViewModel(IEnumerable<Snip> trashedSnips)
{
ArgumentNullException.ThrowIfNull(trashedSnips);

Snips = new ObservableCollection<SnipCardViewModel>(
trashedSnips
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.Select(s => new SnipCardViewModel(s)));
}

public ObservableCollection<SnipCardViewModel> Snips { get; }

public bool HasSnips => Snips.Count > 0;

public bool IsEmpty => Snips.Count == 0;
}
}
102 changes: 102 additions & 0 deletions tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrashViewModel>(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<TrashViewModel>(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<TrashViewModel>(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<TrashViewModel>(vm.CurrentContent);
Assert.Empty(trash.Snips);
}

[Fact]
public async Task NewCli_adds_the_cli_and_writes_icon_bytes_when_provided()
{
Expand Down
Loading