diff --git a/src/DiffEngine.Tests/TrackedKeyCaseTests.cs b/src/DiffEngine.Tests/TrackedKeyCaseTests.cs
new file mode 100644
index 00000000..ab42e2cf
--- /dev/null
+++ b/src/DiffEngine.Tests/TrackedKeyCaseTests.cs
@@ -0,0 +1,37 @@
+///
+/// Tracked keys fold case exactly where inline keys do.
+///
+/// TrackedKeys lower-cased unconditionally while InlineKey folds only on Windows and macOS, and
+/// InlineKey says why: on Linux two paths differing only in case are two files, and giving them
+/// one key means the second takes over the first's entry. For a tracked move that shows up as
+/// ViewerSession.EnqueueTracked dropping one of them - a parameterised test with value=a and
+/// value=A silently loses a pending file.
+///
+///
+/// Asserted as agreement between the two rather than as a platform's answer, so it holds wherever
+/// it runs.
+///
+///
+public class TrackedKeyCaseTests
+{
+ const string upper = "/repo/tests/Value.txt";
+ const string lower = "/repo/tests/value.txt";
+
+ [Test]
+ public async Task MoveKeysFoldWithInlineKeys()
+ {
+ var inlineFolds = InlineKey.For(upper, 1) == InlineKey.For(lower, 1);
+
+ await Assert.That(TrackedKeys.ForMove(upper) == TrackedKeys.ForMove(lower))
+ .IsEqualTo(inlineFolds);
+ }
+
+ [Test]
+ public async Task DeleteKeysFoldWithInlineKeys()
+ {
+ var inlineFolds = InlineKey.For(upper, 1) == InlineKey.For(lower, 1);
+
+ await Assert.That(TrackedKeys.ForDelete(upper) == TrackedKeys.ForDelete(lower))
+ .IsEqualTo(inlineFolds);
+ }
+}
diff --git a/src/DiffEngine/Inline/InlineKey.cs b/src/DiffEngine/Inline/InlineKey.cs
index 465d3e5c..28e49481 100644
--- a/src/DiffEngine/Inline/InlineKey.cs
+++ b/src/DiffEngine/Inline/InlineKey.cs
@@ -22,7 +22,14 @@ public static class InlineKey
///
///
public static string For(string sourceFile, int line) =>
- $"{(caseInsensitivePaths ? sourceFile.ToLowerInvariant() : sourceFile)}|{line}";
+ $"{FoldPath(sourceFile)}|{line}";
+
+ ///
+ /// A path folded the way this machine's file system folds it, so anything else keyed by path
+ /// agrees with about when two paths are one file.
+ ///
+ internal static string FoldPath(string path) =>
+ caseInsensitivePaths ? path.ToLowerInvariant() : path;
static readonly bool caseInsensitivePaths =
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ||
diff --git a/src/DiffEngine/Protocol/TrackedKeys.cs b/src/DiffEngine/Protocol/TrackedKeys.cs
index ce156c93..cfab20c9 100644
--- a/src/DiffEngine/Protocol/TrackedKeys.cs
+++ b/src/DiffEngine/Protocol/TrackedKeys.cs
@@ -2,9 +2,15 @@ namespace DiffEngine;
///
/// Keys for the tray's tracked moves and deletes when they ride the inline listing. Prefixed so
-/// they cannot collide with an inline key — a lower-cased path plus "|" plus a line number, where
-/// a Windows path cannot put a colon at index four — and so only the tray has to know which
+/// they cannot collide with an inline key — a folded path plus "|" plus a line number, where a
+/// Windows path cannot put a colon at index four — and so only the tray has to know which
/// collection a key belongs to. Every other process echoes keys back opaquely.
+///
+/// Folded through , which folds only where the file system does.
+/// Lower-casing unconditionally made two Linux files differing only in case - which is two files,
+/// not one - share a key, and the viewer's enqueue drops the earlier of two entries with the same
+/// key. A parameterised test producing value=a and value=A lost one of them.
+///
///
static class TrackedKeys
{
@@ -13,10 +19,10 @@ static class TrackedKeys
public const string DeletePrefix = "delete:";
public static string ForMove(string temp) =>
- MovePrefix + temp.ToLowerInvariant();
+ MovePrefix + InlineKey.FoldPath(temp);
public static string ForDelete(string file) =>
- DeletePrefix + file.ToLowerInvariant();
+ DeletePrefix + InlineKey.FoldPath(file);
public static bool IsTracked(string key) =>
key.StartsWith(MovePrefix, StringComparison.Ordinal) ||