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