diff --git a/.gitignore b/.gitignore index 03c9b93..93c8f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ obj/ .vs/ +*.kkp 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 5931901..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" @@ -82,10 +83,18 @@ Foreground="#666" HorizontalAlignment="Center" /> + + + + + + + + diff --git a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs index 65da515..28f4279 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs @@ -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("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("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") + { + 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