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 @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Delete a CLI.** A "Delete CLI" action on the CLI view removes an empty CLI
after confirmation. Deletion uses must-be-empty semantics: a CLI with visible
(non-trashed) snips can't be deleted until those snips are removed. The CLI's
icon asset and any leftover trashed snips are cleaned up with it.

## [0.1.0-alpha.1] - 2026-05-30

Expand Down
13 changes: 13 additions & 0 deletions src/Snipdeck.App/Services/WindowsShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ public async Task<bool> ConfirmAsync(string title, string message, string confir
return result == ContentDialogResult.Primary;
}

public async Task NotifyAsync(string title, string message, string buttonText = "OK")
{
var dialog = new ContentDialog
{
Title = title,
Content = message,
CloseButtonText = buttonText,
DefaultButton = ContentDialogButton.Close,
XamlRoot = GetXamlRoot(),
};
_ = await dialog.ShowAsync();
}

public async Task<SnipEditResult?> EditSnipAsync(Snip snip, IReadOnlyList<Cli> availableClis)
{
ArgumentNullException.ThrowIfNull(snip);
Expand Down
5 changes: 5 additions & 0 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Bind Name}"
Expand All @@ -106,6 +107,10 @@
Content="Edit CLI"
Click="OnEditCliClicked" />
<Button Grid.Column="2"
Margin="0,0,8,0"
Content="Delete CLI"
Click="OnDeleteCliClicked" />
<Button Grid.Column="3"
Content="New snip"
Click="OnNewSnipClicked"
Style="{ThemeResource AccentButtonStyle}" />
Expand Down
8 changes: 8 additions & 0 deletions src/Snipdeck.App/Views/ShellPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,13 @@ private void OnEditCliClicked(object sender, RoutedEventArgs e)
ViewModel.EditCurrentCliCommand.Execute(null);
}
}

private void OnDeleteCliClicked(object sender, RoutedEventArgs e)
{
if (ViewModel.DeleteCurrentCliCommand.CanExecute(null))
{
ViewModel.DeleteCurrentCliCommand.Execute(null);
}
}
}
}
5 changes: 5 additions & 0 deletions src/Snipdeck.Core/Abstractions/IShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Task<bool> ConfirmAsync(
string confirmButtonText = "Yes",
string cancelButtonText = "Cancel");

Task NotifyAsync(
string title,
string message,
string buttonText = "OK");

Task<SnipEditResult?> EditSnipAsync(Snip snip, IReadOnlyList<Cli> availableClis);

Task<CliEditResult?> EditCliAsync(Cli cli);
Expand Down
18 changes: 15 additions & 3 deletions src/Snipdeck.Core/Services/IconAssetStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,21 @@ public Task DeleteIconAsync(string relativePath)

public string? ResolveAbsolutePath(string? relativePath)
{
return string.IsNullOrWhiteSpace(relativePath)
? null
: Path.Combine(_baseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(relativePath))
{
return null;
}

var combined = Path.Combine(_baseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
var full = Path.GetFullPath(combined);

// The store is untrusted input and this path feeds File.Delete. Reject
// anything that escapes the managed icons directory — an absolute path
// (Path.Combine silently discards the base for one) or `..` traversal —
// so a malformed IconRef can never touch files outside icon storage.
var iconsRoot = Path.GetFullPath(Path.Combine(_baseDirectory, _iconsSubdirectory));
var prefix = iconsRoot + Path.DirectorySeparatorChar;
return full.StartsWith(prefix, StringComparison.Ordinal) ? full : null;
}
}
}
49 changes: 49 additions & 0 deletions src/Snipdeck.Core/ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,55 @@ private async Task EditCurrentCliAsync()
await SaveAndRefreshAsync().ConfigureAwait(true);
}

[RelayCommand]
private async Task DeleteCurrentCliAsync()
{
if (SelectedCliChoice?.Cli is not { } cli)
{
return;
}

// Must-be-empty semantics: a CLI can only be deleted once its visible
// (non-trashed) snips are gone. Trashed snips don't block — they're
// already soft-deleted and the user can't see them.
var activeSnipCount = _document.Snips.Count(s => s.CliId == cli.Id && !s.IsTrash);
if (activeSnipCount > 0)
{
await _interactions.NotifyAsync(
"Can't delete CLI",
$"“{cli.Name}” still has {activeSnipCount} snip{(activeSnipCount == 1 ? "" : "s")}. " +
"Delete those snips first, then delete the CLI.").ConfigureAwait(true);
return;
}

var confirmed = await _interactions.ConfirmAsync(
"Delete CLI",
$"Delete “{cli.Name}”? This can't be undone.",
"Delete",
"Cancel").ConfigureAwait(true);
if (!confirmed)
{
return;
}

// Remove the CLI and any trashed snips that belonged to it — otherwise
// those snips would be orphaned, pointing at a CLI that no longer exists.
_ = _document.Snips.RemoveAll(s => s.CliId == cli.Id);
_ = _document.Clis.RemoveAll(c => c.Id == cli.Id);

// Persist the removal first; the deleted CLI is no longer in CliChoices
// so SaveAndRefreshAsync falls back to the first choice (Home).
await SaveAndRefreshAsync().ConfigureAwait(true);

// Only after the store is safely persisted do we clean up the icon —
// a best-effort side effect. Doing it earlier would risk deleting the
// asset while a failed save left the store still referencing it.
if (!string.IsNullOrEmpty(cli.IconRef))
{
await _iconStorage.DeleteIconAsync(cli.IconRef).ConfigureAwait(true);
}
}

partial void OnSelectedCliChoiceChanged(CliChoice? value)
{
_suppressShellRefresh = true;
Expand Down
90 changes: 90 additions & 0 deletions tests/Snipdeck.Core.Tests/Services/IconAssetStorageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Snipdeck.Core.Services;

namespace Snipdeck.Core.Tests.Services
{
public sealed class IconAssetStorageTests : IDisposable
{
private readonly string _baseDirectory;
private readonly IconAssetStorage _storage;

public IconAssetStorageTests()
{
_baseDirectory = Path.Combine(Path.GetTempPath(), "snipdeck-icons-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_baseDirectory);
_storage = new IconAssetStorage(_baseDirectory);
}

public void Dispose()
{
if (Directory.Exists(_baseDirectory))
{
Directory.Delete(_baseDirectory, recursive: true);
}
GC.SuppressFinalize(this);
}

[Fact]
public async Task SaveIconAsync_writes_under_icons_and_returns_relative_path()
{
var id = Guid.NewGuid();

var relative = await _storage.SaveIconAsync(id, [0x89, 0x50, 0x4E, 0x47]);

Assert.Equal($"icons/{id:N}.png", relative);
Assert.True(File.Exists(Path.Combine(_baseDirectory, "icons", $"{id:N}.png")));
}

[Fact]
public async Task DeleteIconAsync_removes_a_contained_icon()
{
var id = Guid.NewGuid();
var relative = await _storage.SaveIconAsync(id, [0x89, 0x50, 0x4E, 0x47]);
var absolute = Path.Combine(_baseDirectory, "icons", $"{id:N}.png");

await _storage.DeleteIconAsync(relative);

Assert.False(File.Exists(absolute));
}

[Fact]
public async Task DeleteIconAsync_ignores_parent_directory_traversal()
{
// A file outside the icons directory that a malformed IconRef tries to reach.
var victim = Path.Combine(_baseDirectory, "important.txt");
await File.WriteAllTextAsync(victim, "keep me");

await _storage.DeleteIconAsync("../important.txt");

Assert.True(File.Exists(victim));
}

[Fact]
public async Task DeleteIconAsync_ignores_absolute_paths()
{
var victim = Path.Combine(_baseDirectory, "outside.txt");
await File.WriteAllTextAsync(victim, "keep me");

// Path.Combine(base, absolute) discards base — an unguarded delete would hit this.
await _storage.DeleteIconAsync(victim);

Assert.True(File.Exists(victim));
}

[Fact]
public void ResolveAbsolutePath_returns_null_for_escaping_paths()
{
Assert.Null(_storage.ResolveAbsolutePath("../escape.png"));
Assert.Null(_storage.ResolveAbsolutePath(Path.Combine(_baseDirectory, "outside.png")));
Assert.Null(_storage.ResolveAbsolutePath(null));
}

[Fact]
public void ResolveAbsolutePath_returns_full_path_for_contained_icon()
{
var resolved = _storage.ResolveAbsolutePath("icons/abc.png");

Assert.NotNull(resolved);
Assert.Equal(Path.GetFullPath(Path.Combine(_baseDirectory, "icons", "abc.png")), resolved);
}
}
}
3 changes: 3 additions & 0 deletions tests/Snipdeck.Core.Tests/Support/FakeIconAssetStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public sealed class FakeIconAssetStorage : IIconAssetStorage
{
public Dictionary<Guid, byte[]> Saved { get; } = [];

public List<string> Deleted { get; } = [];

public Task<string> SaveIconAsync(Guid cliId, byte[] bytes)
{
Saved[cliId] = bytes;
Expand All @@ -14,6 +16,7 @@ public Task<string> SaveIconAsync(Guid cliId, byte[] bytes)

public Task DeleteIconAsync(string relativePath)
{
Deleted.Add(relativePath);
return Task.CompletedTask;
}

Expand Down
14 changes: 14 additions & 0 deletions tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ public sealed class FakeShellInteractions : IShellInteractions

public string? LastConfirmTitle { get; private set; }

public string? LastNotifyTitle { get; private set; }

public string? LastNotifyMessage { get; private set; }

public int NotifyCount { get; private set; }

public Snip? LastEditedSnip { get; private set; }

public Cli? LastEditedCli { get; private set; }
Expand All @@ -32,6 +38,14 @@ public Task<bool> ConfirmAsync(string title, string message, string confirmButto
return Task.FromResult(NextConfirmResult);
}

public Task NotifyAsync(string title, string message, string buttonText = "OK")
{
LastNotifyTitle = title;
LastNotifyMessage = message;
NotifyCount++;
return Task.CompletedTask;
}

public Task<SnipEditResult?> EditSnipAsync(Snip snip, IReadOnlyList<Cli> availableClis)
{
LastEditedSnip = snip;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,99 @@ public async Task NewSnip_only_acts_when_a_CLI_is_selected()
Assert.Equal("New", store.Document.Snips[0].Title);
}

[Fact]
public async Task DeleteCurrentCli_is_blocked_when_the_cli_has_active_snips()
{
Cli cli = null!;
var (vm, store, _, ix, _) = await BuildAsync(d => (cli, _) = SeedOneCliOneSnip(d));

// Even with confirmation primed, the must-be-empty guard fires first.
ix.NextConfirmResult = true;
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);

await vm.DeleteCurrentCliCommand.ExecuteAsync(null);

Assert.Single(store.Document.Clis);
Assert.Equal(1, ix.NotifyCount);
Assert.Equal(0, store.SaveCount);
}

[Fact]
public async Task DeleteCurrentCli_does_nothing_when_not_confirmed()
{
Cli cli = null!;
var (vm, store, _, ix, _) = await BuildAsync(d =>
{
cli = new Cli { Name = "empty-app" };
d.Clis.Add(cli);
});

ix.NextConfirmResult = false;
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);

await vm.DeleteCurrentCliCommand.ExecuteAsync(null);

Assert.Single(store.Document.Clis);
Assert.Equal(0, ix.NotifyCount);
Assert.Equal(0, store.SaveCount);
}

[Fact]
public async Task DeleteCurrentCli_removes_cli_and_its_trashed_snips_then_falls_back_to_home()
{
Cli cli = null!;
var (vm, store, _, ix, _) = await BuildAsync(d =>
{
cli = new Cli { Name = "pl-app" };
d.Clis.Add(cli);
// A trashed snip must not block deletion, and must be removed with the CLI.
d.Snips.Add(new Snip { CliId = cli.Id, Title = "old", CommandTemplate = "x", IsTrash = true });
});

ix.NextConfirmResult = true;
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);

await vm.DeleteCurrentCliCommand.ExecuteAsync(null);

Assert.Empty(store.Document.Clis);
Assert.Empty(store.Document.Snips);
Assert.Equal(1, store.SaveCount);
Assert.True(vm.SelectedCliChoice?.IsHome);
}

[Fact]
public async Task DeleteCurrentCli_deletes_the_icon_asset()
{
var icons = new FakeIconAssetStorage();
var cli = new Cli { Name = "pl-app", IconRef = "icons/abc.png" };
var doc = new SnipStoreDocument();
doc.Clis.Add(cli);
var store = new InMemorySnipStore(doc);
var ix = new FakeShellInteractions { NextConfirmResult = true };
var vm = new ShellViewModel(store, new FakeClipboardService(), new FakeClock(DateTimeOffset.UtcNow), ix, icons);
await vm.LoadAsync();
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);

await vm.DeleteCurrentCliCommand.ExecuteAsync(null);

Assert.Contains("icons/abc.png", icons.Deleted);
Assert.Empty(store.Document.Clis);
}

[Fact]
public async Task DeleteCurrentCli_no_ops_on_home()
{
var (vm, store, _, ix, _) = await BuildAsync(d => d.Clis.Add(new Cli { Name = "pl-app" }));

// Selection defaults to the Home entry after load.
ix.NextConfirmResult = true;
await vm.DeleteCurrentCliCommand.ExecuteAsync(null);

Assert.Single(store.Document.Clis);
Assert.Equal(0, ix.NotifyCount);
Assert.Equal(0, store.SaveCount);
}

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