fix(ui): offload UnifiedDiffParser.Parse off the UI thread in DiffViewerViewModel

Both call sites (LoadFilesAsync, OnDisplayedDiffChanged) ran the parser
synchronously on the dispatcher, freezing the window on a large diff. The
Files-mode parse+tree-build now runs via Task.Run behind a ParseOp
OperationStatus wired to the toolbar; the planning-mode parse is
fire-and-forget with a generation counter so a fast second DisplayedDiff
change can't write stale PlanningFiles.
This commit is contained in:
Mika Kuns
2026-08-12 13:19:20 +02:00
parent b9827eac01
commit 9fc8ed391e
5 changed files with 69 additions and 3 deletions
@@ -98,6 +98,14 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
public Action? CloseAction { get; set; }
// Parsing a large diff (UnifiedDiffParser.Parse + DiffTree.Build, both pure CPU work) is
// offloaded to a threadpool thread so it never blocks the UI dispatcher.
public OperationStatus ParseOp { get; } = new();
// Guards the fire-and-forget planning parse below against a stale write: a second
// DisplayedDiff change while the first parse is still running must not clobber PlanningFiles.
private int _planningFilesGeneration;
public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
{
_git = git;
@@ -179,8 +187,18 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
return;
}
var files = UnifiedDiffParser.Parse(raw).ToList();
foreach (var node in DiffTree.Build(files))
List<DiffFileViewModel> files;
List<DiffTreeNodeViewModel> tree;
using (ParseOp.Begin(Loc.T("ops.diff.parsing")))
{
(files, tree) = await Task.Run(() =>
{
var parsed = UnifiedDiffParser.Parse(raw);
return (parsed, DiffTree.Build(parsed));
}, ct);
}
foreach (var node in tree)
FileTree.Add(node);
SelectedNode = DiffTree.FirstLeaf(FileTree);
@@ -254,10 +272,24 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
// DisplayedDiff is set from four places (OnSelectedSubtaskChanged plus three branches of
// ToggleCombinedAsync), and OnSelectedSubtaskChanged is itself a synchronous, non-awaitable
// callback — funneling the offload through this single change handler covers all of them
// without touching every call site. Fire-and-forget is safe because the generation counter
// below discards a parse whose result is no longer the current DisplayedDiff.
partial void OnDisplayedDiffChanged(string value)
{
var generation = ++_planningFilesGeneration;
_ = ApplyParsedPlanningFilesAsync(value, generation);
}
private async Task ApplyParsedPlanningFilesAsync(string diff, int generation)
{
var files = await Task.Run(() => UnifiedDiffParser.Parse(diff));
if (generation != _planningFilesGeneration) return;
PlanningFiles.Clear();
foreach (var file in UnifiedDiffParser.Parse(value))
foreach (var file in files)
PlanningFiles.Add(file);
}