diff --git a/Keybinding.Test/RelativeDataDirectoryTests.cs b/Keybinding.Test/RelativeDataDirectoryTests.cs
new file mode 100644
index 0000000..584c874
--- /dev/null
+++ b/Keybinding.Test/RelativeDataDirectoryTests.cs
@@ -0,0 +1,97 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Keybinding.Test;
+
+using ktsu.Keybinding.Core;
+using ktsu.Keybinding.Core.Models;
+using ktsu.Keybinding.Core.Services;
+
+///
+/// A relative data directory is resolved once, when the repository is built, so a later change of the
+/// current directory (an OpenFileDialog without RestoreDirectory does this) moves neither the files nor
+/// the lock (ktsu-dev/Keybinding#217). These tests change the process-wide current directory, so they
+/// must not run alongside other tests.
+///
+[TestClass]
+[DoNotParallelize]
+public class RelativeDataDirectoryTests
+{
+ private string _root = null!;
+ private string _originalDirectory = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _originalDirectory = Environment.CurrentDirectory;
+ _root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Combine(_root, "a"));
+ Directory.CreateDirectory(Path.Combine(_root, "b"));
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Environment.CurrentDirectory = _originalDirectory;
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public async Task Repository_SavesToTheDirectoryResolvedAtConstruction_AfterTheCurrentDirectoryChanges()
+ {
+ Environment.CurrentDirectory = Path.Combine(_root, "a");
+ JsonKeybindingRepository repository = new("data");
+ await repository.SaveProfileAsync(new Profile("first", "First")).ConfigureAwait(false);
+
+ Environment.CurrentDirectory = Path.Combine(_root, "b");
+ await repository.SaveProfileAsync(new Profile("second", "Second")).ConfigureAwait(false);
+
+ Assert.IsFalse(Directory.Exists(Path.Combine(_root, "b", "data")), "No save may follow the current directory.");
+ JsonKeybindingRepository reader = new(Path.Combine(_root, "a", "data"));
+ IReadOnlyCollection profiles = await reader.LoadAllProfilesAsync().ConfigureAwait(false);
+ Assert.AreEqual("first,second", string.Join(',', profiles.Select(p => p.Id).Order(StringComparer.Ordinal)));
+ }
+
+ [TestMethod]
+ public async Task Manager_RebindAfterTheCurrentDirectoryChanges_IsKeptInTheOriginalDirectory()
+ {
+ Environment.CurrentDirectory = Path.Combine(_root, "a");
+ using (KeybindingManager manager = new("data"))
+ {
+ await manager.InitializeAsync().ConfigureAwait(false);
+ manager.Commands.RegisterCommand(new Command("copy", "Copy"));
+ manager.CreateDefaultProfile();
+ manager.Keybindings.BindChord("copy", Chord.Parse("Ctrl+C"));
+ await manager.SaveAsync().ConfigureAwait(false);
+
+ Environment.CurrentDirectory = Path.Combine(_root, "b");
+ manager.Keybindings.BindChord("copy", Chord.Parse("Ctrl+Shift+C"));
+ await manager.SaveAsync().ConfigureAwait(false);
+ }
+
+ Assert.IsFalse(Directory.Exists(Path.Combine(_root, "b", "data")), "No save may follow the current directory.");
+ Environment.CurrentDirectory = Path.Combine(_root, "a");
+ using KeybindingManager restarted = new("data");
+ await restarted.InitializeAsync().ConfigureAwait(false);
+ Assert.AreEqual(Chord.Parse("Ctrl+Shift+C"), restarted.Keybindings.GetChord("copy"));
+ }
+
+ [TestMethod]
+ public async Task RepositoriesFromOneRelativePathUnderDifferentCurrentDirectories_WriteToTheirOwnDirectories()
+ {
+ Environment.CurrentDirectory = Path.Combine(_root, "a");
+ JsonKeybindingRepository first = new("data");
+ Environment.CurrentDirectory = Path.Combine(_root, "b");
+ JsonKeybindingRepository second = new("data");
+
+ await Task.WhenAll(Enumerable.Range(0, 100).Select(i =>
+ (i % 2 == 0 ? first : second).SaveProfileAsync(new Profile("p" + i, "P" + i)))).ConfigureAwait(false);
+
+ IReadOnlyCollection inA = await new JsonKeybindingRepository(Path.Combine(_root, "a", "data")).LoadAllProfilesAsync().ConfigureAwait(false);
+ IReadOnlyCollection inB = await new JsonKeybindingRepository(Path.Combine(_root, "b", "data")).LoadAllProfilesAsync().ConfigureAwait(false);
+ Assert.AreEqual(50, inA.Count, "Every save through the first repository lands in a/data.");
+ Assert.AreEqual(50, inB.Count, "Every save through the second repository lands in b/data.");
+ }
+}
diff --git a/Keybinding/Services/JsonKeybindingRepository.cs b/Keybinding/Services/JsonKeybindingRepository.cs
index 8da582d..e182071 100644
--- a/Keybinding/Services/JsonKeybindingRepository.cs
+++ b/Keybinding/Services/JsonKeybindingRepository.cs
@@ -51,8 +51,10 @@ public JsonKeybindingRepository(string dataDirectory)
throw new ArgumentException(Constants.ErrorMessages.DataDirectoryNullOrWhitespace, nameof(dataDirectory));
}
- _dataDirectory = dataDirectory.Trim();
- _lock = DirectoryLocks.GetOrAdd(Path.GetFullPath(_dataDirectory), _ => new SemaphoreSlim(1, 1));
+ // Resolve a relative directory once, so a later change of the current directory moves neither
+ // the files this repository reads and writes nor the lock that guards them.
+ _dataDirectory = Path.GetFullPath(dataDirectory.Trim());
+ _lock = DirectoryLocks.GetOrAdd(_dataDirectory, _ => new SemaphoreSlim(1, 1));
_jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,