From 318f385a7b1a92ccda3afed5714eb74c2dfe073e Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sat, 28 Feb 2026 15:13:48 +0300 Subject: [PATCH] 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 --- .../PasswordStore/IPassStoreDirectory.cs | 2 + src/KeyKeeper/PasswordStore/PassStoreEntry.cs | 1 + .../PasswordStore/PassStoreEntryGroup.cs | 30 ++++-- .../ViewModels/UnlockedRepositoryViewModel.cs | 6 ++ src/KeyKeeper/Views/RepositoryWindow.axaml | 2 + src/KeyKeeper/Views/RepositoryWindow.axaml.cs | 57 ++++++++++- .../Views/ToastNotificationHost.axaml | 15 +++ .../Views/ToastNotificationHost.axaml.cs | 97 +++++++++++++++++++ 8 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/KeyKeeper/Views/ToastNotificationHost.axaml create mode 100644 src/KeyKeeper/Views/ToastNotificationHost.axaml.cs diff --git a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs index b5c5acc..044c30d 100644 --- a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs +++ b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; namespace KeyKeeper.PasswordStore; public interface IPassStoreDirectory : IEnumerable { + bool DeleteEntry(Guid id); } diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs index 24a24e3..a67c5f7 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -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; } diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs index 9624cec..bf627d9 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs @@ -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 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 IEnumerable.GetEnumerator() { return ChildEntries.GetEnumerator(); diff --git a/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs b/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs index 24b80b4..fe37cd5 100644 --- a/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs +++ b/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs @@ -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(); diff --git a/src/KeyKeeper/Views/RepositoryWindow.axaml b/src/KeyKeeper/Views/RepositoryWindow.axaml index 332b4cf..1bf3b0d 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml @@ -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 @@ + diff --git a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs index 380c84f..28f4279 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs @@ -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("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("NotificationHost")?.Show("Username copied to clipboard"); } else if (s.Name == "entryCtxMenuCopyPassword") { Clipboard!.SetTextAsync(pwd.Password.Value); + this.FindControlRecursive("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("NotificationHost")?.Show("Entry deleted"); } } } } +} + +public static class VisualTreeExtensions +{ + public static T? FindControlRecursive(this Visual parent, string name) where T : Visual + { + return FindControlRecursiveInternal(parent, name, 0); + } + + private static T? FindControlRecursiveInternal(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(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(content, name, depth + 1); + if (result != null) + return result; + } + } + + return null; + } } \ No newline at end of file diff --git a/src/KeyKeeper/Views/ToastNotificationHost.axaml b/src/KeyKeeper/Views/ToastNotificationHost.axaml new file mode 100644 index 0000000..ab0721b --- /dev/null +++ b/src/KeyKeeper/Views/ToastNotificationHost.axaml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/src/KeyKeeper/Views/ToastNotificationHost.axaml.cs b/src/KeyKeeper/Views/ToastNotificationHost.axaml.cs new file mode 100644 index 0000000..8291734 --- /dev/null +++ b/src/KeyKeeper/Views/ToastNotificationHost.axaml.cs @@ -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 MessageProperty = + AvaloniaProperty.Register(nameof(Message), string.Empty); + + public static readonly StyledProperty DurationProperty = + AvaloniaProperty.Register(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); + } + } +} \ No newline at end of file