From d3f653622864aa62be7febb2849eba8d382a3a9f Mon Sep 17 00:00:00 2001 From: Slavasil Date: Fri, 27 Feb 2026 21:35:20 +0300 Subject: [PATCH 1/3] add context menu for password store entries --- src/KeyKeeper/Views/RepositoryWindow.axaml | 7 ++++++ src/KeyKeeper/Views/RepositoryWindow.axaml.cs | 22 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/KeyKeeper/Views/RepositoryWindow.axaml b/src/KeyKeeper/Views/RepositoryWindow.axaml index 5931901..332b4cf 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml @@ -82,6 +82,13 @@ Foreground="#666" HorizontalAlignment="Center" /> + + + + + + + diff --git a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs index 65da515..380c84f 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs @@ -1,5 +1,4 @@ using System; -using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; @@ -54,4 +53,25 @@ public partial class RepositoryWindow: Window Clipboard!.SetTextAsync(pwd.Password.Value); } } + + private void EntryContextMenuItem_Click(object sender, RoutedEventArgs args) { + if (args.Source is StyledElement s) + { + if (s.DataContext is PassStoreEntryPassword pwd) + { + if (s.Name == "entryCtxMenuCopyUsername") + { + Clipboard!.SetTextAsync(pwd.Username.Value); + } + else if (s.Name == "entryCtxMenuCopyPassword") + { + Clipboard!.SetTextAsync(pwd.Password.Value); + } + else if (s.Name == "entryCtxMenuDelete") + { + Console.WriteLine("DELETE"); + } + } + } + } } \ No newline at end of file From 242dc94903758101cd23130a647d024d93b7c18d Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sat, 28 Feb 2026 15:08:42 +0300 Subject: [PATCH 2/3] add .kkp files (from KeyKeeper itself) to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 03c9b93..93c8f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ obj/ .vs/ +*.kkp From 318f385a7b1a92ccda3afed5714eb74c2dfe073e Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sat, 28 Feb 2026 15:13:48 +0300 Subject: [PATCH 3/3] 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