implement entry deletion and toast notifications

1. add ToastNotificationHost custom control to display popup messages in
   the center of the window
2. add IPassStoreDirectory.DeleteEntry() method and implement it for
   PassStoreEntryGroup
3. make the Delete context menu option delete the selected entry and
   notify the user
This commit is contained in:
2026-02-28 15:13:48 +03:00
parent 242dc94903
commit 318f385a7b
8 changed files with 203 additions and 7 deletions

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
namespace KeyKeeper.PasswordStore;
public interface IPassStoreDirectory : IEnumerable<PassStoreEntry>
{
bool DeleteEntry(Guid id);
}

View File

@@ -7,6 +7,7 @@ namespace KeyKeeper.PasswordStore;
public abstract class PassStoreEntry
{
public Guid Id { get; set; }
public PassStoreEntryGroup? Parent { get; set; }
public DateTime CreationDate { get; protected set; }
public DateTime ModificationDate { get; set; }
public Guid IconType { get; set; }

View File

@@ -45,22 +45,40 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
customGroupSubtype = new Guid(guidBuffer);
}
PassStoreEntryGroup group = new(id, createdAt, modifiedAt, iconType, name, groupType, null, customGroupSubtype);
int entryCount = rd.Read7BitEncodedInt();
List<PassStoreEntry> children = new();
for (int i = 0; i < entryCount; i++)
children.Add(PassStoreEntry.ReadFromStream(str));
{
PassStoreEntry entry = PassStoreEntry.ReadFromStream(str);
entry.Parent = group;
children.Add(entry);
}
group.ChildEntries = children;
return new PassStoreEntryGroup(
id, createdAt, modifiedAt,
iconType, name, groupType, children,
customGroupSubtype
);
return group;
} catch (EndOfStreamException)
{
throw PassStoreFileException.UnexpectedEndOfFile;
}
}
public bool DeleteEntry(Guid id)
{
if (ChildEntries == null)
return false;
for (int i = 0; i < ChildEntries.Count; i++)
{
if (ChildEntries[i].Id == id)
{
ChildEntries.RemoveAt(i);
return true;
}
}
return false;
}
IEnumerator<PassStoreEntry> IEnumerable<PassStoreEntry>.GetEnumerator()
{
return ChildEntries.GetEnumerator();

View File

@@ -33,6 +33,12 @@ public class UnlockedRepositoryViewModel : ViewModelBase
}
}
public void DeleteEntry(Guid id)
{
(passStore.GetRootDirectory() as PassStoreEntryGroup)!.DeleteEntry(id);
OnPropertyChanged(nameof(Passwords));
}
public void Save()
{
passStore.Save();

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:KeyKeeper.ViewModels"
xmlns:i="using:Avalonia.Interactivity"
xmlns:kkp="using:KeyKeeper.Views"
x:Class="KeyKeeper.Views.RepositoryWindow"
Title="KeyKeeper - Password store"
CanResize="False"
@@ -93,6 +94,7 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<kkp:ToastNotificationHost x:Name="NotificationHost" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20" Duration="0:0:2" />
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:LockedRepositoryViewModel}">

View File

@@ -5,6 +5,8 @@ using Avalonia.Interactivity;
using Avalonia.Input;
using KeyKeeper.PasswordStore;
using KeyKeeper.ViewModels;
using Avalonia.VisualTree;
using Avalonia.Controls.Presenters;
namespace KeyKeeper.Views;
@@ -50,7 +52,10 @@ public partial class RepositoryWindow: Window
if (args.Source is StyledElement s)
{
if (s.DataContext is PassStoreEntryPassword pwd)
{
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
}
}
@@ -62,16 +67,66 @@ public partial class RepositoryWindow: Window
if (s.Name == "entryCtxMenuCopyUsername")
{
Clipboard!.SetTextAsync(pwd.Username.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard");
}
else if (s.Name == "entryCtxMenuCopyPassword")
{
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
else if (s.Name == "entryCtxMenuDelete")
{
Console.WriteLine("DELETE");
if (s.DataContext is PassStoreEntryPassword entry)
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteEntry(entry.Id);
}
}
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry deleted");
}
}
}
}
}
public static class VisualTreeExtensions
{
public static T? FindControlRecursive<T>(this Visual parent, string name) where T : Visual
{
return FindControlRecursiveInternal<T>(parent, name, 0);
}
private static T? FindControlRecursiveInternal<T>(Visual parent, string name, int depth) where T : Visual
{
if (parent == null)
return null;
if (parent is T t && parent.Name == name)
return t;
foreach (var child in parent.GetVisualChildren())
{
if (child == null)
continue;
var result = FindControlRecursiveInternal<T>(child, name, depth + 1);
if (result != null)
return result;
}
// Also check logical children if they're not in visual tree
if (parent is ContentPresenter contentPresenter)
{
var content = contentPresenter.Content as Visual;
if (content != null)
{
var result = FindControlRecursiveInternal<T>(content, name, depth + 1);
if (result != null)
return result;
}
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:KeyKeeper.Views"
x:Class="KeyKeeper.Views.ToastNotificationHost"
Opacity="0">
<Border Background="#aa666666"
CornerRadius="18"
Padding="16 12"
IsHitTestVisible="False">
<TextBlock Text="{Binding Message, RelativeSource={RelativeSource AncestorType=local:ToastNotificationHost}}"
TextWrapping="Wrap"
Foreground="White"/>
</Border>
</UserControl>

View File

@@ -0,0 +1,97 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Styling;
namespace KeyKeeper.Views
{
public partial class ToastNotificationHost : UserControl
{
private CancellationTokenSource? _hideCancellation;
private Animation? _fadeOutAnimation;
public static readonly StyledProperty<string> MessageProperty =
AvaloniaProperty.Register<ToastNotificationHost, string>(nameof(Message), string.Empty);
public static readonly StyledProperty<TimeSpan> DurationProperty =
AvaloniaProperty.Register<ToastNotificationHost, TimeSpan>(nameof(Duration), TimeSpan.FromSeconds(5));
public string Message
{
get => GetValue(MessageProperty);
set => SetValue(MessageProperty, value);
}
public TimeSpan Duration
{
get => GetValue(DurationProperty);
set => SetValue(DurationProperty, value);
}
public ToastNotificationHost()
{
InitializeComponent();
_fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromSeconds(0.5),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame
{
Cue = new Cue(0d),
Setters = { new Setter(OpacityProperty, 1.0) },
},
new KeyFrame
{
Cue = new Cue(1d),
Setters = { new Setter(OpacityProperty, 0.0) },
}
}
};
Opacity = 0;
}
public void Show(string message)
{
_hideCancellation?.Cancel();
_hideCancellation = new CancellationTokenSource();
Message = message;
Opacity = 1;
_ = HideAfterDelay(_hideCancellation.Token);
}
private void FadeOut()
{
_fadeOutAnimation?.RunAsync(this);
}
private async Task HideAfterDelay(CancellationToken cancellationToken)
{
try
{
await Task.Delay(Duration, cancellationToken);
if (!cancellationToken.IsCancellationRequested)
{
FadeOut();
}
}
catch (TaskCanceledException)
{
// caught
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_hideCancellation?.Cancel();
base.OnDetachedFromVisualTree(e);
}
}
}