Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions Keybinding.Test/SaveWithoutInitializeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Keybinding.Test;

using ktsu.Keybinding.Core;
using ktsu.Keybinding.Core.Models;

[TestClass]
public class SaveWithoutInitializeTests
{
private string _testDataDirectory = null!;

[TestInitialize]
public void Setup()
{
_testDataDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDataDirectory);
}

[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_testDataDirectory))
{
Directory.Delete(_testDataDirectory, recursive: true);
}
}

private async Task<string> LoadProfileIdsAsync()
{
using KeybindingManager manager = new(_testDataDirectory);
await manager.InitializeAsync().ConfigureAwait(false);
return string.Join(',', manager.Profiles.GetAllProfiles().Select(p => p.Id).Order(StringComparer.Ordinal));
}

[TestMethod]
public async Task SaveAsync_WithoutInitialize_KeepsStoredProfiles()
{
{
using KeybindingManager manager = new(_testDataDirectory);
await manager.InitializeAsync().ConfigureAwait(false);
manager.CreateDefaultProfile();
manager.Profiles.CreateProfile("vim", "Vim");
await manager.SaveAsync().ConfigureAwait(false);
}

{
using KeybindingManager manager = new(_testDataDirectory);
manager.CreateDefaultProfile("other", "Other");
await manager.SaveAsync().ConfigureAwait(false);
}

Assert.AreEqual("default,other,vim", await LoadProfileIdsAsync().ConfigureAwait(false));
}

[TestMethod]
public async Task DeleteProfile_CreatedAndSavedInSameManager_StaysDeleted()
{
{
using KeybindingManager manager = new(_testDataDirectory);
manager.Profiles.CreateProfile("default", "Default");
manager.Profiles.CreateProfile("vim", "Vim");
await manager.SaveAsync().ConfigureAwait(false);

Assert.IsTrue(manager.Profiles.DeleteProfile("vim"));
await manager.SaveAsync().ConfigureAwait(false);
}

Assert.AreEqual("default", await LoadProfileIdsAsync().ConfigureAwait(false));
}

[TestMethod]
public async Task DeleteProfile_ThenRecreate_IsSavedAgain()
{
{
using KeybindingManager manager = new(_testDataDirectory);
await manager.InitializeAsync().ConfigureAwait(false);
manager.Profiles.CreateProfile("vim", "Vim");
await manager.SaveAsync().ConfigureAwait(false);

Assert.IsTrue(manager.Profiles.DeleteProfile("vim"));
await manager.SaveAsync().ConfigureAwait(false);

manager.Profiles.CreateProfile(new Profile("vim", "Vim again"));
await manager.SaveAsync().ConfigureAwait(false);
}

Assert.AreEqual("vim", await LoadProfileIdsAsync().ConfigureAwait(false));
}
}
29 changes: 26 additions & 3 deletions Keybinding/KeybindingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
public sealed class KeybindingManager : IDisposable
{
private bool _disposed;

// Ids of the stored profiles this manager has loaded or saved. SaveAsync deletes a stored profile only if
// it is in here and no longer in memory, so profiles this manager never saw are not taken as deleted.
private readonly HashSet<string> _persistedProfileIds = [];
private readonly Lock _persistedProfileIdsLock = new();

/// <summary>
/// Initializes a new instance of the <see cref="KeybindingManager"/> class with default services
/// </summary>
Expand Down Expand Up @@ -87,6 +93,11 @@
Profiles.CreateProfile(profile);
}

lock (_persistedProfileIdsLock)
{
_persistedProfileIds.UnionWith(profiles.Select(p => p.Id));
}

// Load active profile
string? activeProfileId = await Repository.LoadActiveProfileAsync().ConfigureAwait(false);
if (!string.IsNullOrEmpty(activeProfileId) && Profiles.ProfileExists(activeProfileId))
Expand All @@ -107,16 +118,28 @@
IReadOnlyCollection<Command> commands = Commands.GetAllCommands();
await Repository.SaveCommandsAsync(commands).ConfigureAwait(false);

// Remove stored profiles that were deleted in memory, so they do not come back on the next load
// Remove stored profiles that were deleted in memory, so they do not come back on the next load. Only
// profiles this manager loaded or saved count: one it never saw is not in memory because it was never
// loaded, not because it was deleted.
IReadOnlyCollection<Profile> profiles = Profiles.GetAllProfiles();
HashSet<string> profileIds = [.. profiles.Select(p => p.Id)];
IReadOnlyCollection<Profile> storedProfiles = await Repository.LoadAllProfilesAsync().ConfigureAwait(false);
IEnumerable<string> deletedProfileIds = storedProfiles.Select(p => p.Id).Where(id => !profileIds.Contains(id));
List<string> deletedProfileIds;
lock (_persistedProfileIdsLock)
{
deletedProfileIds = [.. _persistedProfileIds.Where(id => !profileIds.Contains(id))];
}

await AsyncBatchHelper.ForEachAsync(deletedProfileIds, Repository.DeleteProfileAsync).ConfigureAwait(false);

// Save profiles using batch helper
await AsyncBatchHelper.ForEachAsync(profiles, Repository.SaveProfileAsync).ConfigureAwait(false);

lock (_persistedProfileIdsLock)
{
_persistedProfileIds.ExceptWith(deletedProfileIds);
_persistedProfileIds.UnionWith(profileIds);
}

// Save active profile
Profile? activeProfile = Profiles.GetActiveProfile();
await Repository.SaveActiveProfileAsync(activeProfile?.Id).ConfigureAwait(false);
Expand Down Expand Up @@ -176,7 +199,7 @@
DisposalHelper.ThrowIfDisposed(_disposed, this);
Ensure.NotNull(chords);

Profile activeProfile = Profiles.GetActiveProfile() ?? throw new InvalidOperationException("No active profile is set");

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

Check warning on line 202 in Keybinding/KeybindingManager.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove the unused local variable 'activeProfile'.

return OperationHelper.ExecuteWithCount(chords, Keybindings.BindChord);
}
Expand Down
Loading