merge branch 'feature/password-context-menu'

This commit is contained in:
2026-02-28 20:37:22 +03:00
9 changed files with 231 additions and 7 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
bin/
obj/
.vs/
*.kkp

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"
@@ -82,10 +83,18 @@
Foreground="#666"
HorizontalAlignment="Center" />
</StackPanel>
<Border.ContextMenu>
<ContextMenu>
<MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuDelete" Header="Delete" Click="EntryContextMenuItem_Click"/>
</ContextMenu>
</Border.ContextMenu>
</Border>
</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

@@ -1,11 +1,12 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Input;
using KeyKeeper.PasswordStore;
using KeyKeeper.ViewModels;
using Avalonia.VisualTree;
using Avalonia.Controls.Presenters;
namespace KeyKeeper.Views;
@@ -51,7 +52,81 @@ 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");
}
}
}
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);
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")
{
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);
}
}
}