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
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Migrated the test suites to xUnit v3 on Microsoft Testing Platform, replacing
the VSTest stack. Development-only; the shipped app is unaffected.
- Refreshed dependencies to their latest stable releases, including the Windows
App SDK (2.4.0), WebView2, the Windows SDK build tools, SQLite, the dependency
injection container, Spectre.Console and the test tooling.

### Fixed
- The Trash view now updates as soon as a snip is restored or permanently
deleted, and shows its empty-state message once the last item goes. Previously
the list only refreshed after navigating away from Trash and back.
- CI now runs the `Snipdeck.Execution` test suite, on both Ubuntu and Windows.
It was built but never executed, so 61 tests were only ever run locally.

### Changed
- Refreshed dependencies to their latest stable releases, including the Windows
App SDK (2.4.0), WebView2, the Windows SDK build tools, SQLite, the dependency
injection container, Spectre.Console and the test tooling.

## [1.1.0] - 2026-07-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@
<TextBlock Text="Trash is empty."
Style="{ThemeResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="{x:Bind IsEmpty, Converter={StaticResource BoolToVisibility}}" />
Visibility="{x:Bind IsEmpty, Mode=OneWay, Converter={StaticResource BoolToVisibility}}" />

<ItemsControl ItemsSource="{x:Bind Snips}">
<ItemsControl.ItemsPanel>
Expand Down
12 changes: 11 additions & 1 deletion src/Snipdeck.Core/ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,17 @@ private async Task SaveAndRefreshTrashAsync()
_suppressShellRefresh = false;
}

CurrentContent = BuildTrashViewModel();
// Refresh the Trash list in place when it is the live view, rather than
// swapping in a replacement: the content area binds a template per
// view-model type, so a same-type swap leaves the old list on screen.
if (CurrentContent is TrashViewModel trash)
{
trash.Load(_document.Snips.Where(s => s.IsTrash));
}
else
{
CurrentContent = BuildTrashViewModel();
}
}

private async Task SaveAndRefreshAsync()
Expand Down
31 changes: 24 additions & 7 deletions src/Snipdeck.Core/ViewModels/TrashViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,35 @@ 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)));
Load(trashedSnips);
}

public ObservableCollection<SnipCardViewModel> Snips { get; }
/// <summary>The trashed snips currently shown, ordered by title.</summary>
public ObservableCollection<SnipCardViewModel> Snips { get; } = [];

public bool HasSnips => Snips.Count > 0;

public bool IsEmpty => Snips.Count == 0;

/// <summary>
/// Repopulates the list in place. The shell reuses the live instance after a
/// restore or a permanent delete rather than building a replacement, so the
/// content area sees collection-change notifications: its bindings resolve
/// once per template instantiation, and swapping in a new view model of the
/// same type leaves the stale list on screen until the user navigates away.
/// </summary>
public void Load(IEnumerable<Snip> trashedSnips)
{
ArgumentNullException.ThrowIfNull(trashedSnips);

Snips.Clear();
foreach (var snip in trashedSnips.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase))
{
Snips.Add(new SnipCardViewModel(snip));
}

OnPropertyChanged(nameof(HasSnips));
OnPropertyChanged(nameof(IsEmpty));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,89 @@ public async Task DeleteForever_only_removes_the_snip_when_confirmed()
Assert.Empty(trash.Snips);
}

[Fact]
public async Task DeleteForever_refreshes_the_live_trash_view_in_place()
{
// Regression: the shell used to swap in a replacement TrashViewModel, which
// the content area does not re-read for a same-type change — the deleted
// snip stayed on screen until the user navigated away and back.
Cli cli = null!;
var (vm, _, _, ix, _) = await BuildAsync(d =>
{
cli = new Cli { Name = "pl-app" };
d.Clis.Add(cli);
d.Snips.Add(new Snip { CliId = cli.Id, Title = "Keep", CommandTemplate = "k", IsTrash = true });
d.Snips.Add(new Snip { CliId = cli.Id, Title = "Purge", CommandTemplate = "p", IsTrash = true });
});

vm.OpenTrash();
var trash = Assert.IsType<TrashViewModel>(vm.CurrentContent);
var snips = trash.Snips;
var card = trash.Snips.Single(c => c.Title == "Purge");

ix.NextConfirmResult = true;
await vm.DeleteForeverCommand.ExecuteAsync(card);

// Same view model and same collection instance, so the bound list updates.
Assert.Same(trash, vm.CurrentContent);
Assert.Same(snips, trash.Snips);
var remaining = Assert.Single(trash.Snips);
Assert.Equal("Keep", remaining.Title);
}

[Fact]
public async Task Deleting_the_last_trashed_snip_announces_the_empty_state()
{
Cli cli = null!;
var (vm, _, _, ix, _) = await BuildAsync(d =>
{
cli = new Cli { Name = "pl-app" };
d.Clis.Add(cli);
d.Snips.Add(new Snip { CliId = cli.Id, Title = "Last", CommandTemplate = "l", IsTrash = true });
});

vm.OpenTrash();
var trash = Assert.IsType<TrashViewModel>(vm.CurrentContent);
Assert.False(trash.IsEmpty);

var changed = new List<string>();
trash.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

ix.NextConfirmResult = true;
await vm.DeleteForeverCommand.ExecuteAsync(trash.Snips[0]);

Assert.Empty(trash.Snips);
Assert.True(trash.IsEmpty);
Assert.False(trash.HasSnips);
// Without these the "Trash is empty" placeholder never appears.
Assert.Contains(nameof(TrashViewModel.IsEmpty), changed);
Assert.Contains(nameof(TrashViewModel.HasSnips), changed);
}

[Fact]
public async Task RestoreSnip_refreshes_the_live_trash_view_in_place()
{
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 = "Keep", CommandTemplate = "k", IsTrash = true });
d.Snips.Add(new Snip { CliId = cli.Id, Title = "Restore me", CommandTemplate = "r", IsTrash = true });
});

vm.OpenTrash();
var trash = Assert.IsType<TrashViewModel>(vm.CurrentContent);
var snips = trash.Snips;

await vm.RestoreSnipCommand.ExecuteAsync(trash.Snips.Single(c => c.Title == "Restore me"));

Assert.Same(trash, vm.CurrentContent);
Assert.Same(snips, trash.Snips);
var remaining = Assert.Single(trash.Snips);
Assert.Equal("Keep", remaining.Title);
}

[Fact]
public async Task NewCli_adds_the_cli_and_writes_icon_bytes_when_provided()
{
Expand Down
67 changes: 67 additions & 0 deletions tests/Snipdeck.Core.Tests/ViewModels/TrashViewModelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Snipdeck.Core.Models;
using Snipdeck.Core.ViewModels;

namespace Snipdeck.Core.Tests.ViewModels
{
public class TrashViewModelTests
{
private static Snip Trashed(string title)
{
return new Snip { CliId = Guid.NewGuid(), Title = title, CommandTemplate = "x", IsTrash = true };
}

[Fact]
public void Constructor_orders_snips_by_title_case_insensitively()
{
var vm = new TrashViewModel([Trashed("beta"), Trashed("Alpha"), Trashed("gamma")]);

Assert.Equal(["Alpha", "beta", "gamma"], vm.Snips.Select(s => s.Title));
}

[Fact]
public void Empty_trash_reports_the_empty_state()
{
var vm = new TrashViewModel([]);

Assert.True(vm.IsEmpty);
Assert.False(vm.HasSnips);
}

[Fact]
public void Load_repopulates_the_same_collection_instance()
{
// The shell relies on this: the bound list only updates if the collection
// instance survives, because the content area resolves its bindings once.
var vm = new TrashViewModel([Trashed("first")]);
var collection = vm.Snips;

vm.Load([Trashed("second"), Trashed("third")]);

Assert.Same(collection, vm.Snips);
Assert.Equal(["second", "third"], vm.Snips.Select(s => s.Title));
}

[Fact]
public void Load_raises_change_notifications_for_the_computed_state()
{
var vm = new TrashViewModel([Trashed("only")]);
var changed = new List<string>();
vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

vm.Load([]);

Assert.True(vm.IsEmpty);
Assert.False(vm.HasSnips);
Assert.Contains(nameof(TrashViewModel.IsEmpty), changed);
Assert.Contains(nameof(TrashViewModel.HasSnips), changed);
}

[Fact]
public void Load_rejects_a_null_sequence()
{
var vm = new TrashViewModel([]);

_ = Assert.Throws<ArgumentNullException>(() => vm.Load(null!));
}
}
}
Loading