add context menu for groups

This commit is contained in:
2026-05-07 23:26:00 +03:00
parent d1ad7df811
commit 4e53b710a5
9 changed files with 219 additions and 18 deletions

View File

@@ -0,0 +1,5 @@
using System;
namespace KeyKeeper.Models;
public record GroupEditData(string Name, Guid IconType);

View File

@@ -160,6 +160,29 @@ public class UnlockedRepositoryViewModel : ViewModelBase
OnPropertyChanged(nameof(Passwords)); OnPropertyChanged(nameof(Passwords));
} }
public void UpdateGroup(PassStoreEntryGroup group, string newName, Guid newIconType)
{
group.Name = newName;
group.IconType = newIconType;
group.ModificationDate = DateTime.UtcNow;
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void DeleteGroup(PassStoreEntryGroup group)
{
if (rootDirectory == null)
return;
passStore.DeleteEntry(rootDirectory, group.Id);
if (currentDirectory == group && rootDirectory != null)
{
ChangeDirectory(rootDirectory.ChildGroups.FirstOrDefault() ?? rootDirectory);
}
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void Save() public void Save()
{ {
passStore.Save(); passStore.Save();

View File

@@ -16,6 +16,7 @@
<StackPanel Grid.Row="1" <StackPanel Grid.Row="1"
Orientation="Horizontal" Orientation="Horizontal"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Spacing="8" Spacing="8"
Margin="0,16,0,0"> Margin="0,16,0,0">
<Button Content="Save" <Button Content="Save"

View File

@@ -0,0 +1,38 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.ConfirmationDialog"
Title="Confirm Action"
Background="White"
Icon="/Assets/icon.ico"
Width="380" Height="180"
WindowStartupLocation="CenterOwner"
CanResize="False">
<Grid Margin="20" RowDefinitions="*,*,Auto">
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="18"
FontWeight="Bold"
Foreground="#2328C4"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="1"
x:Name="MessageText"
FontSize="14"
Foreground="Black"
TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal"
HorizontalAlignment="Right" Spacing="10" VerticalAlignment="Bottom">
<Button Content="Cancel"
Width="80"
Click="CancelButton_Click"/>
<Button x:Name="ConfirmButton"
Content="Confirm"
Width="80"
Click="ConfirmButton_Click"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,40 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace KeyKeeper.Views;
public partial class ConfirmationDialog : Window
{
public bool Confirmed { get; private set; }
public ConfirmationDialog()
{
InitializeComponent();
}
public void SetContent(string title, string message)
{
TitleText.Text = title;
MessageText.Text = message;
ConfirmButton.Content = "Confirm";
}
public void SetContent(string title, string message, string confirmButtonText)
{
TitleText.Text = title;
MessageText.Text = message;
ConfirmButton.Content = confirmButtonText;
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Confirmed = false;
Close();
}
private void ConfirmButton_Click(object? sender, RoutedEventArgs e)
{
Confirmed = true;
Close();
}
}

View File

@@ -9,7 +9,8 @@
CanResize="False"> CanResize="False">
<StackPanel Margin="20" Spacing="16"> <StackPanel Margin="20" Spacing="16">
<TextBlock Text="Create new group" <TextBlock x:Name="TitleText"
Text="Create new group"
FontSize="20" FontSize="20"
FontWeight="Bold" FontWeight="Bold"
Foreground="#2328C4"/> Foreground="#2328C4"/>
@@ -50,11 +51,11 @@
<Button Content="Cancel" <Button Content="Cancel"
Width="80" Width="80"
Click="CancelButton_Click"/> Click="CancelButton_Click"/>
<Button x:Name="CreateButton" <Button x:Name="ActionButton"
Content="Create" Content="Create"
Width="80" Width="80"
IsEnabled="False" IsEnabled="False"
Click="CreateButton_Click"/> Click="ActionButton_Click"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Window> </Window>

View File

@@ -3,15 +3,14 @@ using System.Collections.Generic;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using KeyKeeper.Models;
using KeyKeeper.PasswordStore; using KeyKeeper.PasswordStore;
namespace KeyKeeper.Views; namespace KeyKeeper.Views;
public partial class CreateGroupDialog : Window public partial class CreateGroupDialog : Window
{ {
public string GroupName { get; private set; } = string.Empty; public GroupEditData? GroupData { get; private set; }
public Guid IconType { get; private set; } = BuiltinEntryIconType.DEFAULT;
public bool Success { get; private set; }
private record IconChoice(Guid Id) private record IconChoice(Guid Id)
{ {
@@ -29,28 +28,44 @@ public partial class CreateGroupDialog : Window
IconListBox.ItemsSource = icons; IconListBox.ItemsSource = icons;
IconListBox.SelectedIndex = 0; IconListBox.SelectedIndex = 0;
NameTextBox.TextChanged += (_, _) => UpdateCreateButtonState(); NameTextBox.TextChanged += (_, _) => UpdateActionButtonState();
KeyDown += OnKeyDown; KeyDown += OnKeyDown;
} }
private void UpdateCreateButtonState() public void SetupForEdit(PassStoreEntryGroup group)
{ {
CreateButton.IsEnabled = !string.IsNullOrWhiteSpace(NameTextBox.Text); TitleText.Text = "Edit group";
ActionButton.Content = "Save";
NameTextBox.Text = group.Name;
for (int i = 0; i < IconListBox.ItemCount; i++)
{
if (IconListBox.Items[i] is IconChoice choice && choice.Id == group.IconType)
{
IconListBox.SelectedIndex = i;
break;
}
}
}
private void UpdateActionButtonState()
{
ActionButton.IsEnabled = !string.IsNullOrWhiteSpace(NameTextBox.Text);
} }
private void OnKeyDown(object? sender, KeyEventArgs e) private void OnKeyDown(object? sender, KeyEventArgs e)
{ {
if (e.Key == Key.Escape) if (e.Key == Key.Escape)
Close(); Close();
else if (e.Key == Key.Enter && CreateButton.IsEnabled) else if (e.Key == Key.Enter && ActionButton.IsEnabled)
Submit(); Submit();
} }
private void CreateButton_Click(object? sender, RoutedEventArgs e) => Submit(); private void ActionButton_Click(object? sender, RoutedEventArgs e) => Submit();
private void CancelButton_Click(object? sender, RoutedEventArgs e) private void CancelButton_Click(object? sender, RoutedEventArgs e)
{ {
Success = false; GroupData = null;
Close(); Close();
} }
@@ -64,11 +79,11 @@ public partial class CreateGroupDialog : Window
return; return;
} }
GroupName = name; var iconType = BuiltinEntryIconType.DEFAULT;
if (IconListBox.SelectedItem is IconChoice choice) if (IconListBox.SelectedItem is IconChoice choice)
IconType = choice.Id; iconType = choice.Id;
Success = true; GroupData = new GroupEditData(name, iconType);
Close(); Close();
} }
} }

View File

@@ -69,6 +69,12 @@
DoubleTapped="Entry_DoubleTapped"> DoubleTapped="Entry_DoubleTapped">
<TextBlock Text="{Binding DisplayName}" <TextBlock Text="{Binding DisplayName}"
Foreground="Black" /> Foreground="Black" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Name="groupCtxMenuEdit" Header="Edit" Click="GroupContextMenuItem_Click" />
<MenuItem Name="groupCtxMenuDelete" Header="Delete" Click="GroupContextMenuItem_Click" />
</ContextMenu>
</Border.ContextMenu>
</Border> </Border>
</TreeDataTemplate> </TreeDataTemplate>
</TreeView.ItemTemplate> </TreeView.ItemTemplate>

View File

@@ -1,10 +1,12 @@
using System; using System;
using System.Linq; using System.Linq;
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.Models;
using KeyKeeper.ViewModels; using KeyKeeper.ViewModels;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
@@ -180,14 +182,14 @@ public partial class RepositoryWindow : Window
vm_.StartLockTimer(); vm_.StartLockTimer();
if (dialog.Success) if (dialog.GroupData != null)
{ {
var group = new PassStoreEntryGroup( var group = new PassStoreEntryGroup(
Guid.NewGuid(), Guid.NewGuid(),
DateTime.UtcNow, DateTime.UtcNow,
DateTime.UtcNow, DateTime.UtcNow,
dialog.IconType, dialog.GroupData.IconType,
dialog.GroupName, dialog.GroupData.Name,
FileFormatConstants.GROUP_TYPE_SIMPLE FileFormatConstants.GROUP_TYPE_SIMPLE
); );
vm.AddGroup(group); vm.AddGroup(group);
@@ -304,6 +306,76 @@ public partial class RepositoryWindow : Window
_contextMenuEntry = null; _contextMenuEntry = null;
} }
private async void GroupContextMenuItem_Click(object sender, RoutedEventArgs args)
{
if (args.Source is not StyledElement s || s.DataContext is not PassStoreEntryGroup group)
return;
if (DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
if (group.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT ||
group.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES ||
group.GroupType == FileFormatConstants.GROUP_TYPE_ROOT)
return;
if (s.Name == "groupCtxMenuEdit")
{
await EditGroup(vm, pageVm, group);
}
else if (s.Name == "groupCtxMenuDelete")
{
await DeleteGroup(group);
}
}
private async Task EditGroup(RepositoryWindowViewModel vm, UnlockedRepositoryViewModel pageVm, PassStoreEntryGroup group)
{
CreateGroupDialog dialog = new();
dialog.SetupForEdit(group);
vm.StopLockTimer();
await dialog.ShowDialog(this);
vm.StartLockTimer();
if (dialog.GroupData != null)
{
pageVm.UpdateGroup(group, dialog.GroupData.Name, dialog.GroupData.IconType);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group updated");
}
}
private async Task DeleteGroup(PassStoreEntryGroup group)
{
ConfirmationDialog confirmDialog = new();
confirmDialog.SetContent(
"Delete Group",
$"Are you sure you want to delete the group '{group.DisplayName}'? This action cannot be undone.",
"Delete"
);
if (DataContext is not RepositoryWindowViewModel vm)
return;
vm.StopLockTimer();
await confirmDialog.ShowDialog(this);
vm.StartLockTimer();
if (confirmDialog.Confirmed)
{
if (vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteGroup(group);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group deleted");
}
}
}
private async void EntryContextMenuItem_Click(object sender, RoutedEventArgs args) private async void EntryContextMenuItem_Click(object sender, RoutedEventArgs args)
{ {
if (args.Source is StyledElement s && s.DataContext is PassStoreEntry ent) if (args.Source is StyledElement s && s.DataContext is PassStoreEntry ent)