diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt
index c9198df8..e596f134 100644
--- a/native/CMakeLists.txt
+++ b/native/CMakeLists.txt
@@ -30,6 +30,9 @@ set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(BUILD_GAMES OFF CACHE BOOL "" FORCE)
set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE)
+# raylib spins for the last part of every frame by default, for timing precision nothing here
+# needs: most of a millisecond of a core per frame, for as long as the viewer is open.
+set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE)
set(SUPPORT_MODULE_RAUDIO OFF CACHE BOOL "" FORCE)
set(SUPPORT_MODULE_RMODELS OFF CACHE BOOL "" FORCE)
# The formats the viewer compares as pictures and raylib can decode. WebP and ICO are also
diff --git a/src/DiffEngine.Tests/OsSettingsResolverTest.cs b/src/DiffEngine.Tests/OsSettingsResolverTest.cs
index 1701ec50..9cf8aaec 100644
--- a/src/DiffEngine.Tests/OsSettingsResolverTest.cs
+++ b/src/DiffEngine.Tests/OsSettingsResolverTest.cs
@@ -11,7 +11,8 @@ public class OsSettingsResolverTest
[Test]
public async Task PathEntriesAreUnquotedAndEmptiesDropped()
{
- var paths = OsSettingsResolver.ParsePath(@"C:\one;""C:\Program Files\two"" ; ;C:\thr|ee;", ';');
+ // NUL rather than a character like |, which is only invalid on Windows
+ var paths = OsSettingsResolver.ParsePath(@"C:\one;""C:\Program Files\two"" ; ;C:\thr" + "\0" + "ee;", ';');
await Assert.That(paths).IsEquivalentTo([@"C:\one", @"C:\Program Files\two"]);
}
diff --git a/src/DiffEngine/Inline/InlineStaging.cs b/src/DiffEngine/Inline/InlineStaging.cs
index c78c4dec..a4acdb6e 100644
--- a/src/DiffEngine/Inline/InlineStaging.cs
+++ b/src/DiffEngine/Inline/InlineStaging.cs
@@ -166,7 +166,41 @@ static bool IsOneCallSite(List<(string PatchPath, InlinePatch Patch)> staged)
return staged.All(_ => _.Patch.LineHint == line);
}
+ ///
+ /// Read again only when the directory has changed since. runs once per
+ /// verification and used to read and parse every staged patch each time, so a run with a few
+ /// hundred staged was reading them a few hundred times over. Creating or deleting a trio is
+ /// what changes a directory's write time, and overwriting one keeps the name - which is derived
+ /// from the call site and framework that matching reads - so what is cached still matches.
+ ///
static List<(string PatchPath, InlinePatch Patch)> ReadStaged(string directory)
+ {
+ DateTime written;
+ try
+ {
+ written = Directory.GetLastWriteTimeUtc(directory);
+ }
+ catch (Exception exception)
+ when (exception is IOException or UnauthorizedAccessException)
+ {
+ return [];
+ }
+
+ if (stagedCache.TryGetValue(directory, out var cached) &&
+ cached.Written == written)
+ {
+ return cached.Patches;
+ }
+
+ var patches = ReadStagedFiles(directory);
+ stagedCache[directory] = (written, patches);
+ return patches;
+ }
+
+ static readonly ConcurrentDictionary Patches)> stagedCache =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ static List<(string PatchPath, InlinePatch Patch)> ReadStagedFiles(string directory)
{
var result = new List<(string, InlinePatch)>();
string[] files;
diff --git a/src/DiffEngine/Process/ProcessCleanup.cs b/src/DiffEngine/Process/ProcessCleanup.cs
index 3b925380..3d104c46 100644
--- a/src/DiffEngine/Process/ProcessCleanup.cs
+++ b/src/DiffEngine/Process/ProcessCleanup.cs
@@ -68,6 +68,14 @@ public static void Kill(string command)
Logging.Write($"Kill: {command}. Matching count: {matchingCommands.Count}");
if (matchingCommands.Count == 0)
{
+ // Only when someone will read it. This is the usual outcome of a passing verification,
+ // and on Linux and macOS the list is every process the user owns, so the joined string
+ // was hundreds of KB of garbage per test with logging off.
+ if (!Logging.enabled)
+ {
+ return;
+ }
+
var separator = Environment.NewLine + "\t";
var joined = string.Join(separator, Commands.Select(_ => _.Command));
Logging.Write($"No matching commands. All commands: {separator}{joined}.");
diff --git a/src/DiffEngine/Tray/PiperClient.cs b/src/DiffEngine/Tray/PiperClient.cs
index 1caca83f..7dfc04c1 100644
--- a/src/DiffEngine/Tray/PiperClient.cs
+++ b/src/DiffEngine/Tray/PiperClient.cs
@@ -1,3 +1,4 @@
+using System.Net.NetworkInformation;
static class PiperClient
{
public static int Port = 3492;
@@ -86,6 +87,12 @@ public static string BuildMovePayload(string tempFile, string targetFile, string
///
static bool Send(string payload)
{
+ if (!PortIsHeld())
+ {
+ HandleNoListener(payload);
+ return false;
+ }
+
try
{
InnerSend(payload);
@@ -100,6 +107,14 @@ static bool Send(string payload)
static async Task SendAsync(string payload, Cancel cancel)
{
+ // Before the listener check, so a cancelled send says so whether or not a tray is there
+ cancel.ThrowIfCancellationRequested();
+ if (!PortIsHeld())
+ {
+ HandleNoListener(payload);
+ return false;
+ }
+
try
{
await InnerSendAsync(payload, cancel);
@@ -123,6 +138,17 @@ static async Task SendAsync(string payload, Cancel cancel)
}
}
+ static void HandleNoListener(string payload) =>
+ Trace.WriteLine(
+ $"""
+ Failed to send payload to DiffEngineTray.
+
+ Payload:
+ {payload}
+
+ Nothing is listening on the tray's port.
+ """);
+
static void HandleSendException(string payload, Exception exception) =>
Trace.WriteLine(
$"""
@@ -188,6 +214,32 @@ static async Task InnerSendAsync(string payload, Cancel cancel)
}
}
+ ///
+ /// Whether anything is listening, asked of the OS rather than found out by connecting. Whether
+ /// a tray runs is read once per process, so after it exits every move and delete still came
+ /// here, and a connect to a port nobody holds is not refused at once everywhere: where the SYN
+ /// is dropped it runs to its timeout, two seconds a send. The listener table answers in well
+ /// under a millisecond. A table that cannot be read leaves the connect to decide.
+ ///
+ static bool PortIsHeld()
+ {
+ try
+ {
+ var port = Port;
+ return IPGlobalProperties.GetIPGlobalProperties()
+ .GetActiveTcpListeners()
+ .Any(_ => _.Port == port);
+ }
+ catch (NetworkInformationException)
+ {
+ return true;
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return true;
+ }
+ }
+
static IPEndPoint GetEndpoint() =>
new(IPAddress.Loopback, Port);
}
\ No newline at end of file
diff --git a/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt b/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt
index 06f3420c..ed81591f 100644
--- a/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt
+++ b/src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt
@@ -10,11 +10,7 @@ Payload:
"ProcessId":10
}
-Exception:
-System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it.
- at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
- at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
- at System.Net.Sockets.TcpClient.CompleteConnectAsync(ValueTask task),
+Nothing is listening on the tray's port.,
Failed to send payload to DiffEngineTray.
Payload:
@@ -23,9 +19,5 @@ Payload:
}
-Exception:
-System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it.
- at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
- at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
- at System.Net.Sockets.TcpClient.CompleteConnectAsync(ValueTask task)
+Nothing is listening on the tray's port.
]
\ No newline at end of file
diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs
index d4a853f8..b75475cc 100644
--- a/src/DiffEngineTray/Tracker.cs
+++ b/src/DiffEngineTray/Tracker.cs
@@ -82,10 +82,27 @@ void RemoveAndKill(TrackedMove tacked)
{
return;
}
+ // A pair found different, and untouched since, is still different. The scan runs every two
+ // seconds and read both files through again each time, which for a few large same-size
+ // pairs - bitmaps, fixed-size data - was hundreds of megabytes a scan for as long as they
+ // stayed pending.
+ var stamp = Stamp(move);
+ if (stamp is not null &&
+ differing.TryGetValue(move.Temp, out var known) &&
+ known == stamp)
+ {
+ return;
+ }
+
try
{
if (!await FileComparer.FilesAreEqual(move.Temp, move.Target))
{
+ if (stamp is not null)
+ {
+ differing[move.Temp] = stamp.Value;
+ }
+
return;
}
}
@@ -99,6 +116,23 @@ void RemoveAndKill(TrackedMove tacked)
RemoveAndKill(pair.Value);
}
+ readonly ConcurrentDictionary differing = new(StringComparer.OrdinalIgnoreCase);
+
+ static (long, DateTime, long, DateTime)? Stamp(TrackedMove move)
+ {
+ try
+ {
+ var temp = new FileInfo(move.Temp);
+ var target = new FileInfo(move.Target);
+ return (temp.Length, temp.LastWriteTimeUtc, target.Length, target.LastWriteTimeUtc);
+ }
+ catch (Exception exception)
+ when (exception is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
void ToggleActive()
{
if (TrackingAny)
@@ -613,6 +647,15 @@ bool InnerMove(TrackedMove move, AcceptBatch batch)
return true;
}
+ // Nothing waiting will change these, and every retry is another 400ms of a frozen
+ // menu - an accept-all in a read-only workspace sat through all eight for every move
+ if (CannotEverMove(move))
+ {
+ Log.Warning("Could not accept `{Name}`: the target is read-only or its directory is missing. Kept pending", move.Name);
+ acceptFailed?.Invoke(move);
+ return false;
+ }
+
var locked = FindLockedFiles(move);
if (locked == null)
{
@@ -645,6 +688,27 @@ bool InnerMove(TrackedMove move, AcceptBatch batch)
return false;
}
+ static bool CannotEverMove(TrackedMove move)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(move.Target);
+ if (directory is not null &&
+ !Directory.Exists(directory))
+ {
+ return true;
+ }
+
+ return File.Exists(move.Target) &&
+ new FileInfo(move.Target).IsReadOnly;
+ }
+ catch (Exception exception)
+ when (exception is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
bool ShouldKill(TrackedMove move, LockedFiles locked, AcceptBatch batch)
{
if (move.KillLockingProcess ||
diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs
index f0315edc..cb32a5c0 100644
--- a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs
+++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs
@@ -62,10 +62,30 @@ public bool Present(Screen screen)
return false;
}
- Thread.Sleep(frameMilliseconds);
+ Wait();
return true;
}
+ ///
+ /// Until the next frame is due or input arrives, whichever is first. Thread.Sleep(16) woke on
+ /// the default 15.6ms timer tick after the one it asked for, so about 31ms: half the frame
+ /// rate, with every key and click waiting out the rest of it. 15 lands on the next tick, and
+ /// input ends the wait at once. Hidden, which a tray keeps it for days, there is nothing to
+ /// draw, so it wakes a few times a second rather than sixty.
+ ///
+ void Wait()
+ {
+ var timeout = form.Visible ? frameMilliseconds - 1 : hiddenMilliseconds;
+ MsgWaitForMultipleObjectsEx(0, IntPtr.Zero, (uint) timeout, allInput, inputAvailable);
+ }
+
+ const int hiddenMilliseconds = 100;
+ const uint allInput = 0x04FF;
+ const uint inputAvailable = 0x0004;
+
+ [DllImport("user32.dll")]
+ static extern uint MsgWaitForMultipleObjectsEx(uint count, IntPtr handles, uint milliseconds, uint wakeMask, uint flags);
+
public ViewerInput Poll() =>
form.IsDisposed ? default : form.Drain();
diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs
index 254099b2..25582c5a 100644
--- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs
+++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs
@@ -502,7 +502,8 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds)
Cellular(bounds.X, bounds.Y, gutter, bounds.Height));
Painter.Draw(
graphics,
- RowText.Flatten(row.Text),
+ // No wider than the pane can show in pixels, which no line of characters can exceed
+ RowText.Clip(RowText.Flatten(row.Text), bounds.Width),
font,
Palette.Foreground(row.Kind),
Cellular(bounds.X + gutter, bounds.Y, bounds.Width - gutter, bounds.Height));
diff --git a/src/DiffEngineViewer/DiffRows.cs b/src/DiffEngineViewer/DiffRows.cs
index 708ae671..bb3af4ac 100644
--- a/src/DiffEngineViewer/DiffRows.cs
+++ b/src/DiffEngineViewer/DiffRows.cs
@@ -1,3 +1,5 @@
+using DiffPlex;
+using DiffPlex.Chunkers;
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
@@ -14,14 +16,22 @@ public static (IReadOnlyList Left, IReadOnlyList Right) Build(string l
// on indentation or a trailing space came back Unchanged on every row, so the panes drew no
// markers, NextChange found nothing, and the reviewer was shown a failure with no visible
// difference. Whitespace is exactly what the F# layout convention is about.
- var model = SideBySideDiffBuilder.Diff(
+ var model = builder.BuildDiffModel(
rightText,
leftText,
- ignoreWhiteSpace: false,
+ ignoreWhitespace: false,
ignoreCase: false);
return (Convert(model.NewText.Lines), Convert(model.OldText.Lines));
}
+ ///
+ /// Lines chunked into lines, and each line's words into the whole line. The builder diffs the
+ /// words of every modified pair to fill SubPieces, which nothing here reads, and that pass
+ /// grows with the square of the line length - minified JSON, a base64 blob. A line that is its
+ /// own single word leaves the rows identical and the pass trivial.
+ ///
+ static readonly SideBySideDiffBuilder builder = new(Differ.Instance, LineChunker.Instance, LineChunker.Instance);
+
static List Convert(List lines)
{
var rows = new List(lines.Count);
diff --git a/src/DiffEngineViewer/Model/RowText.cs b/src/DiffEngineViewer/Model/RowText.cs
index 91a677cf..6a148aba 100644
--- a/src/DiffEngineViewer/Model/RowText.cs
+++ b/src/DiffEngineViewer/Model/RowText.cs
@@ -5,6 +5,30 @@
///
static class RowText
{
+ ///
+ /// No more of a row than can be on screen. Nothing scrolls horizontally, so a character past
+ /// the window's width is never drawn - but a renderer still laid out and measured the whole
+ /// line, and a one megabyte minified line cost most of a second a paint. Every character is
+ /// at least one cell wide, so the window's width in characters is always enough. Never ends
+ /// between the halves of a surrogate pair.
+ ///
+ public static string Clip(string text, int cells)
+ {
+ if (text.Length <= cells)
+ {
+ return text;
+ }
+
+ var length = Math.Max(0, cells);
+ if (length > 0 &&
+ char.IsHighSurrogate(text[length - 1]))
+ {
+ length--;
+ }
+
+ return text[..length];
+ }
+
public static string Flatten(string text)
{
if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0)
diff --git a/src/DiffEngineViewer/Native/ScreenPayload.cs b/src/DiffEngineViewer/Native/ScreenPayload.cs
index 6efd34d2..00a4934b 100644
--- a/src/DiffEngineViewer/Native/ScreenPayload.cs
+++ b/src/DiffEngineViewer/Native/ScreenPayload.cs
@@ -34,8 +34,8 @@ public void Build(Screen screen)
(subtitleOffset, subtitleLength) = Add(screen.Subtitle);
(statusOffset, statusLength) = Add(screen.Status);
- panes[0] = AddPane(screen.Left);
- panes[1] = AddPane(screen.Right);
+ panes[0] = AddPane(screen.Left, screen.Columns);
+ panes[1] = AddPane(screen.Right, screen.Columns);
foreach (var button in screen.Buttons)
{
@@ -157,13 +157,15 @@ unsafe DeviewScreen Native(
MenuRow = menuRow
};
- DeviewPane AddPane(Pane pane)
+ DeviewPane AddPane(Pane pane, int columns)
{
var (headerOffset, headerLength) = Add(pane.Header);
var rowOffset = rows.Count;
foreach (var row in pane.Rows)
{
- var (textOffset, textLength) = Add(row.Text);
+ // Clipped to the window, for the reason RowText.Clip gives: marshalled and laid out
+ // whole every frame otherwise
+ var (textOffset, textLength) = Add(RowText.Clip(row.Text, columns));
rows.Add(
new()
{
diff --git a/src/DiffEngineViewer/QueueProjection.cs b/src/DiffEngineViewer/QueueProjection.cs
index aa103523..45e1300b 100644
--- a/src/DiffEngineViewer/QueueProjection.cs
+++ b/src/DiffEngineViewer/QueueProjection.cs
@@ -38,12 +38,38 @@ public static IReadOnlyList Order(IReadOnlyList entries)
buckets.Add(null);
}
+ // Each entry's group worked out once, and its mates collected in one pass, rather than every
+ // entry asking every other one for a key built from two new strings. This runs twice per
+ // change to the queue, under the lock the render loop takes, and at a few hundred entries
+ // the pairwise version was tens of milliseconds and megabytes of garbage each time.
+ var groups = new string?[entries.Count];
+ var mates = new Dictionary<(string?, string), List>();
+ for (var index = 0; index < entries.Count; index++)
+ {
+ var entry = entries[index];
+ if (TestGroup(entry) is not { } group)
+ {
+ continue;
+ }
+
+ groups[index] = group;
+ var key = (entry.Solution, group);
+ if (!mates.TryGetValue(key, out var list))
+ {
+ list = [];
+ mates[key] = list;
+ }
+
+ list.Add(entry);
+ }
+
var result = new List(entries.Count);
var emitted = new HashSet(ReferenceEqualityComparer.Instance);
foreach (var bucket in buckets)
{
- foreach (var entry in entries)
+ for (var index = 0; index < entries.Count; index++)
{
+ var entry = entries[index];
if (entry.Solution != bucket ||
!emitted.Add(entry))
{
@@ -51,16 +77,14 @@ public static IReadOnlyList Order(IReadOnlyList entries)
}
result.Add(entry);
- if (TestGroup(entry) is not { } group)
+ if (groups[index] is not { } group)
{
continue;
}
- foreach (var mate in entries)
+ foreach (var mate in mates[(bucket, group)])
{
- if (mate.Solution == bucket &&
- TestGroup(mate) == group &&
- emitted.Add(mate))
+ if (emitted.Add(mate))
{
result.Add(mate);
}
@@ -387,25 +411,26 @@ static string[] Labels(IReadOnlyList entries)
return labels;
}
+ ///
+ /// Counted once per label rather than each entry compared with every other, since this runs
+ /// every frame over the whole queue.
+ ///
static List Collisions(IReadOnlyList entries, string[] labels)
{
- var collisions = new List();
+ var counts = new Dictionary<(string?, string), int>();
for (var index = 0; index < entries.Count; index++)
{
- if (LabelPath(entries[index]) is null)
- {
- continue;
- }
+ var key = (entries[index].Solution, labels[index]);
+ counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1;
+ }
- for (var other = 0; other < entries.Count; other++)
+ var collisions = new List();
+ for (var index = 0; index < entries.Count; index++)
+ {
+ if (LabelPath(entries[index]) is not null &&
+ counts[(entries[index].Solution, labels[index])] > 1)
{
- if (other != index &&
- entries[other].Solution == entries[index].Solution &&
- labels[other] == labels[index])
- {
- collisions.Add(index);
- break;
- }
+ collisions.Add(index);
}
}
diff --git a/src/DiffEngineViewer/SelectionText.cs b/src/DiffEngineViewer/SelectionText.cs
index 2aaa992f..7a52dcb1 100644
--- a/src/DiffEngineViewer/SelectionText.cs
+++ b/src/DiffEngineViewer/SelectionText.cs
@@ -132,18 +132,51 @@ public static string All(QueueEntry entry, PaneSide side) =>
/// What the status line says while something is selected. The universal statement about a
/// selection: the heads that can draw a highlight also draw this, and the one that cannot
/// still says a selection exists and how much of one.
+ ///
+ /// Counted from the spans rather than by building the text, because this runs every frame for
+ /// as long as a selection exists: ctrl+a over a large file built megabytes of string sixty
+ /// times a second only to measure it. The counts are what would produce - one
+ /// line per non-filler row, joined by one newline each.
+ ///
///
public static string Summary(TextSelection selection, QueueEntry entry)
{
- var text = Of(selection, entry);
- if (text.Length == 0)
+ var rows = Rows(entry, selection.Side);
+ var (startRow, _) = selection.Start;
+ var (endRow, _) = selection.End;
+ var lines = 0;
+ var length = 0;
+ for (var index = Math.Max(0, startRow); index <= endRow && index < rows.Count; index++)
+ {
+ var row = rows[index];
+ if (row.Kind == RowKind.Filler)
+ {
+ continue;
+ }
+
+ lines++;
+ length += Span(selection, selection.Side, index, row.Text).Length;
+ }
+
+ if (lines == 0)
{
return "nothing selected";
}
- var lines = text.Count(_ => _ == '\n') + 1;
- var characters = $"{text.Length} character{(text.Length == 1 ? "" : "s")}";
- return lines == 1 ? $"selected {characters}" : $"selected {lines} lines, {characters}";
+ // The newlines joining the lines
+ length += lines - 1;
+ if (length == 0)
+ {
+ return "nothing selected";
+ }
+
+ var characters = $"{length} character{(length == 1 ? "" : "s")}";
+ if (lines == 1)
+ {
+ return $"selected {characters}";
+ }
+
+ return $"selected {lines} lines, {characters}";
}
static int ClampRow(int row, IReadOnlyList rows) =>
diff --git a/todo.md b/todo.md
index 21a20f45..18ee9bc2 100644
--- a/todo.md
+++ b/todo.md
@@ -180,20 +180,20 @@ Library and inline
## Perf
-- [ ] **Queue projection is O(n²) per frame and per mutation under the lock** (`src/DiffEngineViewer/QueueProjection.cs`). `Order` calls `TestGroup` (two string allocations) for every pair and runs twice per inline change and per `Sync`; `Rows`/`Labels`/`Collisions` run every frame with n² comparisons, and grouped entries always collide so all four passes run. Compute the group key once per entry, group with a dictionary, detect collisions with a `(solution, label)` count, and cache `Rows` per queue instance.
+- [x] **Queue projection is O(n²) per frame and per mutation under the lock** (`src/DiffEngineViewer/QueueProjection.cs`). `Order` calls `TestGroup` (two string allocations) for every pair and runs twice per inline change and per `Sync`; `Rows`/`Labels`/`Collisions` run every frame with n² comparisons, and grouped entries always collide so all four passes run. Compute the group key once per entry, group with a dictionary, detect collisions with a `(solution, label)` count, and cache `Rows` per queue instance.
- [ ] **An attached viewer polls `ListFull` five times a second, even while hidden.** The owner re-serialises every patch (base64 twice) inside its gate and the viewer re-parses all of it (`src/DiffEngineViewer/Ipc/OwnerLink.cs:100`, `src/DiffEngine/Protocol/ViewerListing.cs`, `src/DiffEngineTray/OwnedInlineHost.cs:259-284`). Add a generation or etag and answer "unchanged"; poll slower while hidden.
-- [ ] **On macOS and Linux every passing verification builds a string of every process's command line**, even with logging off (`src/DiffEngine/Process/ProcessCleanup.cs:66-71`; the Unix `FindAll` ignores the name filter). Guard with `Logging.enabled`, and keep only commands that start with a resolved tool's exe path.
-- [ ] `SelectionText.Summary` rebuilds the whole selected text every frame (`src/DiffEngineViewer/ScreenBuilder.cs:248`, `SelectionText.cs:131-142`), and a right-click builds both whole sides just to test for emptiness (`MenuState.cs:76`). Count from span lengths, once per selection.
-- [ ] DiffPlex computes word-level sub-diffs that are never read, has no cost cap for large wholly-different files, and runs under the lock for inline changes and tracked arrivals (`src/DiffEngineViewer/DiffRows.cs:17-22`, `QueueEntry.cs:59-63`). Use a whole-line chunker for the word pass; guard with an edit-distance lower bound.
+- [x] **On macOS and Linux every passing verification builds a string of every process's command line**, even with logging off (`src/DiffEngine/Process/ProcessCleanup.cs:66-71`; the Unix `FindAll` ignores the name filter). Guard with `Logging.enabled`, and keep only commands that start with a resolved tool's exe path.
+- [x] `SelectionText.Summary` rebuilds the whole selected text every frame (`src/DiffEngineViewer/ScreenBuilder.cs:248`, `SelectionText.cs:131-142`), and a right-click builds both whole sides just to test for emptiness (`MenuState.cs:76`). Count from span lengths, once per selection.
+- [x] DiffPlex computes word-level sub-diffs that are never read, has no cost cap for large wholly-different files, and runs under the lock for inline changes and tracked arrivals (`src/DiffEngineViewer/DiffRows.cs:17-22`, `QueueEntry.cs:59-63`). Use a whole-line chunker for the word pass; guard with an edit-distance lower bound.
- [ ] macOS repaints the whole window every frame (`native/swift/Sources/Deview/Runtime.swift:139-140`). Redraw only when the frame, bounds or a picture stamp change, and cache scaled pictures.
-- [ ] Windows `Thread.Sleep(16)` sleeps about 30 ms at the default timer resolution, and a hidden process wakes about 34 times a second forever (`src/DiffEngineViewer.Windows/FormsViewerWindow.cs:65`). Use `MsgWaitForMultipleObjectsEx`, with a longer timeout while hidden.
+- [x] Windows `Thread.Sleep(16)` sleeps about 30 ms at the default timer resolution, and a hidden process wakes about 34 times a second forever (`src/DiffEngineViewer.Windows/FormsViewerWindow.cs:65`). Use `MsgWaitForMultipleObjectsEx`, with a longer timeout while hidden.
- [ ] Windows image panes rescale from full resolution and redraw the checkerboard on every paint (11 to 40 ms per image), and decode on the UI thread (`ViewerCanvas.cs:385-420`, `ImageCache.cs:56-71`). Cache the composited scaled bitmap per path, stamp and size.
-- [ ] All three heads lay out each row's full text though only about 35 cells fit (`ViewerCanvas.cs:503-508`, `src/DiffEngineViewer/Native/ScreenPayload.cs:164-177`); a 1 MB minified line costs about 0.8 s per paint on Windows. Truncate to the visible columns before drawing or marshalling.
-- [ ] raylib busy-waits the last 5% of every frame: `set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE)` in `native/CMakeLists.txt`.
-- [ ] `InlineStaging.Clear` walks the `obj` tree and re-reads and parses every staged `.inlinepatch` on each verification (`src/DiffEngine/Inline/InlineStaging.cs:94-192`). Cache per directory keyed on `LastWriteTimeUtc`.
-- [ ] Tray: `SafeMove`'s 8 × 400 ms retry runs on the UI thread even for failures that cannot clear, such as a read-only target or a missing directory (`src/DiffEngineTray/Tracker.cs:535-585`, `FileEx.cs:73-90`). Retry only sharing violations.
-- [ ] Tray: the 2 s scan re-reads every equal-size, different pair from scratch (`Tracker.cs:81-97`, `FileComparer.cs:18-57`). Cache length and write time with the last result.
-- [ ] `PiperClient` has no unowned-port memory for 3492, and `TrayAvailable` is cached at type init, so after the tray exits every send pays a refused connect (`src/DiffEngine/Tray/PiperClient.cs:138-153`, `PendingFiles.cs:47-49`).
+- [x] All three heads lay out each row's full text though only about 35 cells fit (`ViewerCanvas.cs:503-508`, `src/DiffEngineViewer/Native/ScreenPayload.cs:164-177`); a 1 MB minified line costs about 0.8 s per paint on Windows. Truncate to the visible columns before drawing or marshalling.
+- [x] raylib busy-waits the last 5% of every frame: `set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE)` in `native/CMakeLists.txt`.
+- [x] `InlineStaging.Clear` walks the `obj` tree and re-reads and parses every staged `.inlinepatch` on each verification (`src/DiffEngine/Inline/InlineStaging.cs:94-192`). Cache per directory keyed on `LastWriteTimeUtc`.
+- [x] Tray: `SafeMove`'s 8 × 400 ms retry runs on the UI thread even for failures that cannot clear, such as a read-only target or a missing directory (`src/DiffEngineTray/Tracker.cs:535-585`, `FileEx.cs:73-90`). Retry only sharing violations.
+- [x] Tray: the 2 s scan re-reads every equal-size, different pair from scratch (`Tracker.cs:81-97`, `FileComparer.cs:18-57`). Cache length and write time with the last result.
+- [x] `PiperClient` has no unowned-port memory for 3492, and `TrayAvailable` is cached at type init, so after the tray exits every send pays a refused connect (`src/DiffEngine/Tray/PiperClient.cs:138-153`, `PendingFiles.cs:47-49`).
## Appendix: repro tests