mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-04-19 11:06:28 +03:00
merge branch 'feature/password-context-menu'
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
.vs/
|
.vs/
|
||||||
|
*.kkp
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace KeyKeeper.PasswordStore;
|
namespace KeyKeeper.PasswordStore;
|
||||||
|
|
||||||
public interface IPassStoreDirectory : IEnumerable<PassStoreEntry>
|
public interface IPassStoreDirectory : IEnumerable<PassStoreEntry>
|
||||||
{
|
{
|
||||||
|
bool DeleteEntry(Guid id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace KeyKeeper.PasswordStore;
|
|||||||
public abstract class PassStoreEntry
|
public abstract class PassStoreEntry
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
public PassStoreEntryGroup? Parent { get; set; }
|
||||||
public DateTime CreationDate { get; protected set; }
|
public DateTime CreationDate { get; protected set; }
|
||||||
public DateTime ModificationDate { get; set; }
|
public DateTime ModificationDate { get; set; }
|
||||||
public Guid IconType { get; set; }
|
public Guid IconType { get; set; }
|
||||||
|
|||||||
@@ -45,22 +45,40 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
|
|||||||
customGroupSubtype = new Guid(guidBuffer);
|
customGroupSubtype = new Guid(guidBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PassStoreEntryGroup group = new(id, createdAt, modifiedAt, iconType, name, groupType, null, customGroupSubtype);
|
||||||
|
|
||||||
int entryCount = rd.Read7BitEncodedInt();
|
int entryCount = rd.Read7BitEncodedInt();
|
||||||
List<PassStoreEntry> children = new();
|
List<PassStoreEntry> children = new();
|
||||||
for (int i = 0; i < entryCount; i++)
|
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(
|
return group;
|
||||||
id, createdAt, modifiedAt,
|
|
||||||
iconType, name, groupType, children,
|
|
||||||
customGroupSubtype
|
|
||||||
);
|
|
||||||
} catch (EndOfStreamException)
|
} catch (EndOfStreamException)
|
||||||
{
|
{
|
||||||
throw PassStoreFileException.UnexpectedEndOfFile;
|
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()
|
IEnumerator<PassStoreEntry> IEnumerable<PassStoreEntry>.GetEnumerator()
|
||||||
{
|
{
|
||||||
return ChildEntries.GetEnumerator();
|
return ChildEntries.GetEnumerator();
|
||||||
|
|||||||
@@ -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()
|
public void Save()
|
||||||
{
|
{
|
||||||
passStore.Save();
|
passStore.Save();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:KeyKeeper.ViewModels"
|
xmlns:vm="using:KeyKeeper.ViewModels"
|
||||||
xmlns:i="using:Avalonia.Interactivity"
|
xmlns:i="using:Avalonia.Interactivity"
|
||||||
|
xmlns:kkp="using:KeyKeeper.Views"
|
||||||
x:Class="KeyKeeper.Views.RepositoryWindow"
|
x:Class="KeyKeeper.Views.RepositoryWindow"
|
||||||
Title="KeyKeeper - Password store"
|
Title="KeyKeeper - Password store"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
@@ -82,10 +83,18 @@
|
|||||||
Foreground="#666"
|
Foreground="#666"
|
||||||
HorizontalAlignment="Center" />
|
HorizontalAlignment="Center" />
|
||||||
</StackPanel>
|
</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>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
<kkp:ToastNotificationHost x:Name="NotificationHost" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20" Duration="0:0:2" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
<DataTemplate DataType="{x:Type vm:LockedRepositoryViewModel}">
|
<DataTemplate DataType="{x:Type vm:LockedRepositoryViewModel}">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using KeyKeeper.PasswordStore;
|
using KeyKeeper.PasswordStore;
|
||||||
using KeyKeeper.ViewModels;
|
using KeyKeeper.ViewModels;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using Avalonia.Controls.Presenters;
|
||||||
|
|
||||||
namespace KeyKeeper.Views;
|
namespace KeyKeeper.Views;
|
||||||
|
|
||||||
@@ -51,7 +52,81 @@ public partial class RepositoryWindow: Window
|
|||||||
if (args.Source is StyledElement s)
|
if (args.Source is StyledElement s)
|
||||||
{
|
{
|
||||||
if (s.DataContext is PassStoreEntryPassword pwd)
|
if (s.DataContext is PassStoreEntryPassword pwd)
|
||||||
|
{
|
||||||
Clipboard!.SetTextAsync(pwd.Password.Value);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/KeyKeeper/Views/ToastNotificationHost.axaml
Normal file
15
src/KeyKeeper/Views/ToastNotificationHost.axaml
Normal 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>
|
||||||
97
src/KeyKeeper/Views/ToastNotificationHost.axaml.cs
Normal file
97
src/KeyKeeper/Views/ToastNotificationHost.axaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user