diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs index b6670ebc..79786c49 100644 --- a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Media; @@ -60,6 +61,24 @@ public partial class DiffTextView : UserControl private bool _renderersInstalled; + // Above this many raw diff lines, DiffAlignment.Build moves to a background thread instead + // of running inline in ReloadFile. Build is O(lines) plus one word-diff LCS per 1:1 changed + // pair, and each LCS is up to MaxWordDiffTokens² (400² = 160k int cells, ~640KB) — a diff + // this size can plausibly stack up enough such pairs to blow a 16ms frame, and Planning mode + // instantiates one DiffTextView per changed file in an ItemsControl, so several can hit this + // at once. A line-count threshold was chosen over chunked/incremental alignment because Build + // is already pure and side-effect-free (safe to run on Task.Run as-is), whereas incremental + // construction would need to fragment its LCS/gap-detection state across calls for no benefit + // to the common case. Below the threshold, staying synchronous avoids a placeholder frame + // (there is nothing to show until the first build completes) for the overwhelming majority of + // diffs, which finish well under a frame either way. + private const int AsyncBuildLineThreshold = 500; + + // Bumped by every ReloadFile call; a background build only applies its result if this is + // still the generation that started it, so a fast File-to-File switch during a slow build + // can't overwrite the newer file's rows with a stale AlignedDiff. + private int _buildGeneration; + public DiffTextView() { InitializeComponent(); @@ -97,9 +116,29 @@ public partial class DiffTextView : UserControl /// Recomputes the alignment (word-diff LCS) for the current and then /// refreshes everything downstream of it. Only ever needed when the file actually changes. + /// Small diffs build inline; large ones move to a background thread (see + /// ) and keep showing the previous file's rows until + /// the new build lands, rather than flashing an empty frame. private void ReloadFile() { - _aligned = DiffAlignment.Build(File?.Lines); + var lines = File?.Lines; + var generation = ++_buildGeneration; + + if (lines is null || lines.Count <= AsyncBuildLineThreshold) + { + _aligned = DiffAlignment.Build(lines); + RefreshLayout(); + return; + } + + _ = BuildAsync(lines, generation); + } + + private async Task BuildAsync(IReadOnlyList lines, int generation) + { + var aligned = await Task.Run(() => DiffAlignment.Build(lines)); + if (generation != _buildGeneration) return; // superseded by a newer File in the meantime. + _aligned = aligned; RefreshLayout(); }