feat(transport-ui): import Map step + per-line diff view (M8 E2)

This commit is contained in:
Joseph Doherty
2026-06-18 07:21:23 -04:00
parent e67587ec93
commit c8211f6363
5 changed files with 733 additions and 4 deletions
@@ -0,0 +1,133 @@
@using System.Text.Json
@*
LineDiffView (Component #24, Task M8 E2).
Renders a single Code field's "lineDiff" payload — the line-level diff that the
importer attaches to a Modified ImportPreviewItem's FieldDiffJson for code fields —
as a compact, GitHub-style +/- list. Pure presentation: it takes an already-parsed
JsonElement (the value of the "lineDiff" key) and walks its "hunks" array.
hunk op ∈ "context" | "add" | "remove" (lowercase):
context → muted line, both old/new line numbers
add → green-ish line, new line number only (no oldLineNo)
remove → red-ish line, old line number only (no newLineNo)
When the payload's "truncated" flag is true a trailing marker summarises the
addedCount / removedCount the diff could not show in full.
No third-party diff/charting library — Bootstrap utility classes + a small
monospace block only.
*@
@if (_hunks.Count == 0 && !_truncated)
{
<div class="text-muted small fst-italic" data-testid="line-diff-empty">No line-level changes.</div>
}
else
{
<div class="border rounded bg-body-tertiary font-monospace small overflow-auto"
style="max-height: 22rem;" data-testid="line-diff">
@foreach (var hunk in _hunks)
{
var (rowCls, gutter, sign) = hunk.Op switch
{
"add" => ("bg-success-subtle text-success-emphasis", FormatGutter(null, hunk.NewLineNo), "+"),
"remove" => ("bg-danger-subtle text-danger-emphasis", FormatGutter(hunk.OldLineNo, null), "-"),
_ => ("text-body-secondary", FormatGutter(hunk.OldLineNo, hunk.NewLineNo), " "),
};
<div class="d-flex @rowCls" data-testid="@($"line-diff-{hunk.Op}")">
<span class="px-2 text-body-tertiary text-nowrap user-select-none"
style="min-width: 6.5rem;">@gutter</span>
<span class="px-1 text-nowrap user-select-none">@sign</span>
<span class="px-2 text-break flex-grow-1" style="white-space: pre-wrap;">@hunk.Text</span>
</div>
}
</div>
@if (_truncated)
{
<div class="text-muted small fst-italic mt-1" data-testid="line-diff-truncated">
… diff truncated (+@_addedCount / -@_removedCount more)
</div>
}
}
@code {
/// <summary>
/// The parsed value of a Modified item's <c>FieldDiffJson</c> code field's
/// <c>lineDiff</c> key. When null the component renders nothing meaningful —
/// callers should only render it for code fields that actually carry a
/// <c>lineDiff</c> object.
/// </summary>
[Parameter]
public JsonElement? LineDiff { get; set; }
private readonly List<Hunk> _hunks = new();
private bool _truncated;
private int _addedCount;
private int _removedCount;
private sealed record Hunk(string Op, string Text, int? OldLineNo, int? NewLineNo);
protected override void OnParametersSet()
{
_hunks.Clear();
_truncated = false;
_addedCount = 0;
_removedCount = 0;
if (LineDiff is not { ValueKind: JsonValueKind.Object } payload)
{
return;
}
if (payload.TryGetProperty("truncated", out var truncatedEl)
&& truncatedEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
_truncated = truncatedEl.GetBoolean();
}
if (payload.TryGetProperty("addedCount", out var addedEl)
&& addedEl.ValueKind == JsonValueKind.Number)
{
_addedCount = addedEl.GetInt32();
}
if (payload.TryGetProperty("removedCount", out var removedEl)
&& removedEl.ValueKind == JsonValueKind.Number)
{
_removedCount = removedEl.GetInt32();
}
if (payload.TryGetProperty("hunks", out var hunksEl)
&& hunksEl.ValueKind == JsonValueKind.Array)
{
foreach (var h in hunksEl.EnumerateArray())
{
if (h.ValueKind != JsonValueKind.Object)
{
continue;
}
var op = h.TryGetProperty("op", out var opEl) && opEl.ValueKind == JsonValueKind.String
? opEl.GetString() ?? "context"
: "context";
var text = h.TryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String
? textEl.GetString() ?? string.Empty
: string.Empty;
int? oldLineNo = h.TryGetProperty("oldLineNo", out var oldEl) && oldEl.ValueKind == JsonValueKind.Number
? oldEl.GetInt32()
: null;
int? newLineNo = h.TryGetProperty("newLineNo", out var newEl) && newEl.ValueKind == JsonValueKind.Number
? newEl.GetInt32()
: null;
_hunks.Add(new Hunk(op, text, oldLineNo, newLineNo));
}
}
}
private static string FormatGutter(int? oldLineNo, int? newLineNo)
{
var left = oldLineNo?.ToString() ?? string.Empty;
var right = newLineNo?.ToString() ?? string.Empty;
return $"{left,3} {right,3}";
}
}