diff --git a/README.md b/README.md index dedcff4..80b1c6c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A Blazor component for displaying side-by-side text differences with character-l - Adjacent character highlights merge into smooth pill shapes - Opt-in viewport virtualization for large documents - Collapse/expand the comparison viewport +- Hide unchanged regions with configurable context and reveal buttons - Ignore case and whitespace options - Custom header with diff statistics - Custom CSS class and attribute support @@ -87,6 +88,8 @@ No manual script tags or service registration are required. Virtualized and nonw | `DeferDiff` | `bool` | `false` | Keep the last comparison while loading inputs; set to `false` to compare the latest text and options | | `Virtualize` | `bool` | `false` | Render only visible, fixed-height rows with synchronized scrolling on both axes | | `WrapLines` | `bool` | `true` | Wrap long lines in nonvirtualized mode; virtualized mode always disables wrapping | +| `HideUnchangedLines` | `bool` | `false` | Fold unchanged regions outside the context around changes; reveal a block from either pane | +| `ContextLines` | `int` | `3` | Unchanged lines to retain before and after each change when folding; must be nonnegative | | `CollapseContent` | `bool` | `false` | Collapse the view; ignored in virtualized mode | | `MaxHeight` | `int` | `300` | Collapsed maximum height, or the fixed viewport height in virtualized mode (px; must be positive when virtualizing) | | `IgnoreCase` | `bool` | `false` | Ignore case differences | @@ -116,6 +119,23 @@ The component reuses its last diff when the text values and ignore options have Within each word, adjacent characters with the same change type share a highlight span. Consecutive changed whitespace is grouped too. By default all lines are rendered; enable `Virtualize` to limit rendering to the viewport. +### Hide Unchanged Lines + +```razor + +``` + +Regions that are unchanged on both sides are replaced by **Show N unchanged lines** buttons, keeping the requested context around every change. Revealing a block from either pane reveals it on both sides. Original line numbers and header statistics continue to describe the full comparison. + +`ContextLines="0"` shows only changed lines and hidden-region buttons. An entirely unchanged comparison becomes one revealable block per pane. Empty-side comparisons still show additions or deletions, and the ignore options determine which lines count as unchanged. + +Revealed blocks remain open during ordinary parent rerenders and wrapping, height, or virtualization changes. New comparison inputs, ignore options, or folding settings reset the hidden regions. While `DeferDiff` is true, folding options apply to the last completed comparison. + +Folding works in wrapped, nonwrapping, and virtualized modes. It is separate from `CollapseContent`, which only limits the viewport height. Virtualization renders the folded row list, including fixed-height hidden-region buttons, rather than leaving gaps for hidden source lines. Neither option avoids calculating the full diff. + ### Nonwrapping Comparisons Actual newline characters are preserved in every mode. `WrapLines` controls only whether a long source line wraps visually. @@ -146,7 +166,7 @@ The panes have a fixed viewport height controlled by `MaxHeight`. `CollapseConte Virtualization reduces rendering and DOM costs, not the initial full-document diff calculation or the memory needed for the diff model. A single enormous line still needs to be compared and rendered when visible. -Offscreen rows are not present in the DOM, so browser find, text selection, and printing cannot include them. Turn virtualization off and expand the view when you need the full document in the page. Existing wrapped rendering remains the default. +Offscreen and folded rows are not present in the DOM, so browser find, text selection, and printing cannot include them. Turn virtualization and `HideUnchangedLines` off and expand the view when you need the full document in the page. Existing wrapped rendering remains the default. The [large-file demo](https://lzinga.github.io/BlazorTextDiff/large-files) includes generated JSON comparisons, document-size and viewport controls, and virtualization and wrapping toggles. It also demonstrates `DeferDiff` while the inputs are generated in separate stages. diff --git a/src/BlazorTextDiff.Web/Pages/Index.razor b/src/BlazorTextDiff.Web/Pages/Index.razor index b64ce0d..6e5502d 100644 --- a/src/BlazorTextDiff.Web/Pages/Index.razor +++ b/src/BlazorTextDiff.Web/Pages/Index.razor @@ -59,6 +59,8 @@ else CollapseContent="@collapseContent" MaxHeight="@maxHeight" WrapLines="@wrapLines" + HideUnchangedLines="@hideUnchangedLines" + ContextLines="@contextLines" IgnoreCase="@ignoreCase" IgnoreWhiteSpace="@ignoreWhiteSpace" Class="demo-diff" @@ -96,7 +98,7 @@ else
- Display options Wrapping & height + Display options Wrapping, height & unchanged lines
@@ -117,6 +119,22 @@ else

Wrapping changes only how long lines fit on screen; source line breaks stay intact. A height limit adds Show more / Show less controls. It does not hide unchanged lines.

+
+
+ + +
+
+ + +
+
+

Keep this many unchanged lines around each change and fold the rest. A Show unchanged lines button reveals a block in both panes. Try zero context on a short sample; line numbers and totals still refer to the full comparison.

For large documents and synchronized scrolling, try the large-file demo.

@@ -205,6 +223,8 @@ RetryLimit = 3") private bool wrapLines = true; private bool collapseContent; private int maxHeight = 360; + private bool hideUnchangedLines; + private int contextLines = 3; private Sample SelectedSample => Samples.First(sample => sample.Id == sampleId); private bool HasPendingEdits => draftOldText != oldText || draftNewText != newText; @@ -228,6 +248,8 @@ RetryLimit = 3") IgnoreCase=""{ignoreCase.ToString().ToLowerInvariant()}"" IgnoreWhiteSpace=""{ignoreWhiteSpace.ToString().ToLowerInvariant()}"" WrapLines=""{wrapLines.ToString().ToLowerInvariant()}"" + HideUnchangedLines=""{hideUnchangedLines.ToString().ToLowerInvariant()}"" + ContextLines=""{contextLines}"" CollapseContent=""{collapseContent.ToString().ToLowerInvariant()}"" MaxHeight=""{maxHeight}"" />"; diff --git a/src/BlazorTextDiff.Web/Pages/LargeFiles.razor b/src/BlazorTextDiff.Web/Pages/LargeFiles.razor index eca9280..2032be4 100644 --- a/src/BlazorTextDiff.Web/Pages/LargeFiles.razor +++ b/src/BlazorTextDiff.Web/Pages/LargeFiles.razor @@ -46,7 +46,7 @@ } else { - All rows are rendered, even outside the height limit. Large samples can take longer to display. Use Show more to remove the height limit. + All displayed rows are rendered, even outside the height limit. Large samples can take longer to display. Use Show more to remove the height limit. }

@@ -75,6 +75,8 @@ DeferDiff="@isLoading" Virtualize="@virtualize" WrapLines="@WrapLongLines" + HideUnchangedLines="@hideUnchangedLines" + ContextLines="@contextLines" MaxHeight="@viewportHeight" CollapseContent="true" Class="demo-diff" @@ -96,14 +98,37 @@ }

+
+ Hide unchanged lines Focus on changes and nearby context +
+
+
+ + +
+
+ + +
+
+

Keep unchanged context before and after each change. The remaining blocks become buttons that reveal their lines in both panes. This works with or without virtualization and does not change the line numbers or full-comparison totals.

+

Revealed blocks stay open while changing wrapping or viewport height. New texts, ignore options, or folding settings reset the hidden regions.

+
+
+
Which rendering mode should I use? Performance, selection & scrolling
Virtualized: fewer rows in the page
Only visible rows and a small buffer exist in the DOM. Rows have a fixed height and never wrap. MaxHeight sets the viewport height; CollapseContent is ignored, so there is no expand button.
-
All rows: the complete text is rendered
-
Turn virtualization off for full-document browser find, selection, or printing. Expand the view with Show more when you need to see the full result. Rendering 20,000 records in this mode can pause the page.
+
Nonvirtualized: all displayed rows are rendered
+
Turn virtualization and Hide unchanged lines off for full-document browser find, selection, or printing. Expand the view with Show more when you need to see the full result. Rendering 20,000 records in this mode can pause the page.
Rendering is not diff calculation
Both modes compute the full diff up front and keep its model in memory. Virtualization reduces rendering work; it does not speed up the comparison algorithm or move it off the UI thread.
Wrapping is a display choice
@@ -134,6 +159,8 @@ private int viewportHeight = 300; private bool virtualize = true; private bool wrapLines = true; + private bool hideUnchangedLines; + private int contextLines = 3; private bool isLoading = true; private string loadingMessage = "Preparing the original JSON…"; private string? oldText; @@ -151,6 +178,8 @@ DeferDiff=""@isLoading"" Virtualize=""{virtualize.ToString().ToLowerInvariant()}"" WrapLines=""{WrapLongLines.ToString().ToLowerInvariant()}"" + HideUnchangedLines=""{hideUnchangedLines.ToString().ToLowerInvariant()}"" + ContextLines=""{contextLines}"" CollapseContent=""true"" MaxHeight=""{viewportHeight}"" />"; diff --git a/src/BlazorTextDiff/DiffDisplayRow.cs b/src/BlazorTextDiff/DiffDisplayRow.cs new file mode 100644 index 0000000..5bf09fd --- /dev/null +++ b/src/BlazorTextDiff/DiffDisplayRow.cs @@ -0,0 +1,17 @@ +namespace BlazorTextDiff; + +/// +/// An aligned source line or a summary of consecutive hidden unchanged lines. +/// +public sealed class DiffDisplayRow +{ + public int LineIndex { get; } + public int HiddenLineCount { get; } + public bool IsHidden => HiddenLineCount > 0; + + internal DiffDisplayRow(int lineIndex, int hiddenLineCount = 0) + { + LineIndex = lineIndex; + HiddenLineCount = hiddenLineCount; + } +} diff --git a/src/BlazorTextDiff/ScrollableTextDiff.razor b/src/BlazorTextDiff/ScrollableTextDiff.razor index fd79769..6074c8f 100644 --- a/src/BlazorTextDiff/ScrollableTextDiff.razor +++ b/src/BlazorTextDiff/ScrollableTextDiff.razor @@ -7,12 +7,14 @@
+ MaxHeight="@MaxHeight" DisplayRows="@DisplayRows" + OnShowHiddenLines="@OnShowHiddenLines" @ref="leftPane" />
+ MaxHeight="@MaxHeight" DisplayRows="@DisplayRows" + OnShowHiddenLines="@OnShowHiddenLines" @ref="rightPane" />
@@ -21,6 +23,8 @@ [Parameter] public bool Virtualize { get; set; } [Parameter] public bool CollapseContent { get; set; } [Parameter] public int MaxHeight { get; set; } = 300; + [Parameter] public ICollection? DisplayRows { get; set; } + [Parameter] public EventCallback OnShowHiddenLines { get; set; } private TextDiffPane leftPane = default!; private TextDiffPane rightPane = default!; diff --git a/src/BlazorTextDiff/TextDiff.razor b/src/BlazorTextDiff/TextDiff.razor index f3f4f72..99cf874 100644 --- a/src/BlazorTextDiff/TextDiff.razor +++ b/src/BlazorTextDiff/TextDiff.razor @@ -14,7 +14,8 @@ @if ((Virtualize || !WrapLines) && diff is not null) { + CollapseContent="@isCollapsed" MaxHeight="@MaxHeight" + DisplayRows="@displayRows" OnShowHiddenLines="@ShowHiddenLines" /> } else { @@ -23,10 +24,12 @@ @if (diff is not null) {
- +
- +
} @@ -74,6 +77,18 @@ /// [Parameter] public bool WrapLines { get; set; } = true; + /// + /// Hide unchanged regions outside the context around changes. + /// Hidden regions can be revealed from either pane. + /// + [Parameter] public bool HideUnchangedLines { get; set; } + + /// + /// The number of unchanged lines to retain before and after each change. + /// Must be nonnegative. Only affects display when HideUnchangedLines is true. + /// + [Parameter] public int ContextLines { get; set; } = 3; + /// /// The text before any changes. /// @@ -109,6 +124,11 @@ private bool isCollapsed; private bool? previousCollapseContent; private DiffInputs? previousInputs; + private SideBySideDiffModel? visibilityModel; + private bool? previousHideUnchangedLines; + private int previousContextLines; + private bool[]? visibleLines; + private List? displayRows; private readonly record struct DiffInputs( string OldText, string NewText, bool IgnoreCase, bool IgnoreWhiteSpace); @@ -120,6 +140,7 @@ { if (Virtualize) ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxHeight); + ArgumentOutOfRangeException.ThrowIfNegative(ContextLines); if (previousCollapseContent != CollapseContent) { @@ -127,9 +148,14 @@ previousCollapseContent = CollapseContent; } - if (DeferDiff) - return; + if (!DeferDiff) + UpdateDiff(); + + UpdateVisibility(); + } + private void UpdateDiff() + { var inputs = new DiffInputs( OldText ?? string.Empty, NewText ?? string.Empty, IgnoreCase, IgnoreWhiteSpace); if (previousInputs == inputs) @@ -162,6 +188,83 @@ previousInputs = inputs; } + private void UpdateVisibility() + { + if (ReferenceEquals(visibilityModel, diff) + && previousHideUnchangedLines == HideUnchangedLines + && previousContextLines == ContextLines) + return; + + if (diff is null || !HideUnchangedLines) + { + visibleLines = null; + displayRows = null; + } + else + { + var count = diff.OldText.Lines.Count; + visibleLines = new bool[count]; + var lastChange = -1; + + // Two linear passes avoid repeatedly scanning overlapping context ranges. + for (var i = 0; i < count; i++) + { + if (IsChangedRow(i)) + lastChange = i; + visibleLines[i] = lastChange >= 0 && i - lastChange <= ContextLines; + } + + var nextChange = -1; + for (var i = count - 1; i >= 0; i--) + { + if (IsChangedRow(i)) + nextChange = i; + if (nextChange >= 0 && nextChange - i <= ContextLines) + visibleLines[i] = true; + } + + displayRows = BuildDisplayRows(visibleLines); + + bool IsChangedRow(int index) => + diff.OldText.Lines[index].Type != ChangeType.Unchanged + || diff.NewText.Lines[index].Type != ChangeType.Unchanged; + } + + visibilityModel = diff; + previousHideUnchangedLines = HideUnchangedLines; + previousContextLines = ContextLines; + } + + private static List BuildDisplayRows(bool[] visibility) + { + var rows = new List(); + for (var i = 0; i < visibility.Length;) + { + if (visibility[i]) + { + rows.Add(new DiffDisplayRow(i)); + i++; + continue; + } + + var start = i; + while (i < visibility.Length && !visibility[i]) + i++; + rows.Add(new DiffDisplayRow(start, i - start)); + } + return rows; + } + + private void ShowHiddenLines(DiffDisplayRow row) + { + // A queued click may belong to a comparison or context setting that was replaced. + if (visibleLines is null || displayRows is null || !row.IsHidden || !displayRows.Contains(row)) + return; + + Array.Fill(visibleLines, true, row.LineIndex, row.HiddenLineCount); + displayRows = BuildDisplayRows(visibleLines); + } + /// /// For lines whose only sub-piece changes are whitespace characters, /// downgrade them to Unchanged so they don't show as modified. diff --git a/src/BlazorTextDiff/TextDiffPane.razor b/src/BlazorTextDiff/TextDiffPane.razor index 848a2ec..170c0c1 100644 --- a/src/BlazorTextDiff/TextDiffPane.razor +++ b/src/BlazorTextDiff/TextDiffPane.razor @@ -2,14 +2,33 @@
@if (Model?.Lines is not null) { - @if (Virtualize) + @if (DisplayRows is not null) + { + + @if (Virtualize) + { + + } + else + { + @foreach (var row in DisplayRows) + { + @RenderDisplayRow(row) + } + } + + } + else if (Virtualize) { ? DisplayRows { get; set; } + [Parameter] public EventCallback OnShowHiddenLines { get; set; } internal ElementReference ScrollElement { get; private set; } private const int VirtualizedRowHeight = 30; private bool IsScrollable => Virtualize || !WrapLines; + private bool HasFocusTarget => IsScrollable || DisplayRows is not null; private string CssClass => $"diff-pane-{PanePosition.ToString().ToLowerInvariant()}" + (IsScrollable ? " diff-scroll-pane" : string.Empty) + (Virtualize ? " diff-virtualized-pane" : !WrapLines ? " diff-nowrap-pane" : string.Empty); @@ -54,6 +76,27 @@ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxHeight); } + private RenderFragment RenderDisplayRow(DiffDisplayRow row) + { + if (!row.IsHidden) + return RenderLine(Model.Lines[row.LineIndex]); + + var label = $"Show {row.HiddenLineCount:N0} unchanged {(row.HiddenLineCount == 1 ? "line" : "lines")}"; + return @ + + + ; + } + + private async Task RevealRowAsync(DiffDisplayRow row) + { + await OnShowHiddenLines.InvokeAsync(row); + await ScrollElement.FocusAsync(preventScroll: true); + } + private readonly RenderFragment RenderLine = diffLine => @
+ +
@((MarkupString)(diffLine.Position?.ToString() ?? " ")) diff --git a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css index d8e0277..4979a6f 100644 --- a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css +++ b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.css @@ -555,3 +555,32 @@ table.diff td.line-number:focus { word-break: normal; overflow-wrap: normal; } + +.diff-container table.diff .diff-hidden-summary td { + background-color: var(--diff-bg-tertiary); + color: var(--diff-text-muted); +} + +.diff-show-hidden { + max-width: 100%; + margin: 0; + padding: 0; + border: 0; + background: none; + color: var(--diff-text-accent); + font: inherit; + line-height: inherit; + white-space: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.diff-show-hidden:hover { + color: var(--diff-text-primary); +} + +.diff-show-hidden:focus-visible { + outline: 2px solid var(--diff-text-accent); + outline-offset: 2px; +} diff --git a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css index d794353..5e93df4 100644 --- a/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css +++ b/src/BlazorTextDiff/wwwroot/css/BlazorDiff.min.css @@ -1,2 +1,3 @@ :root{--diff-bg-primary:#ffffff;--diff-bg-secondary:#f6f8fa;--diff-bg-tertiary:#f1f3f4;--diff-border-primary:#e1e4e8;--diff-border-secondary:#30363d;--diff-text-primary:#24292e;--diff-text-secondary:#586069;--diff-text-muted:#656d76;--diff-text-accent:#0969da;--diff-addition-bg:#e6ffed;--diff-addition-border:#2ea043;--diff-addition-highlight:#7ce89b;--diff-deletion-bg:#ffeef0;--diff-deletion-border:#f85149;--diff-deletion-highlight:#f9a8b0;--diff-modification-bg:#fff8c5;--diff-modification-border:#fb8500;--diff-modification-highlight:#ffc833;--diff-char-radius:3px;--diff-char-padding:1px 2px;--diff-word-radius:3px;--diff-word-padding:1px 0;--diff-shadow:0 1px 3px rgba(0,0,0,0.1);--diff-shadow-hover:0 2px 6px rgba(0,0,0,0.15);--stats-primary-bg:#0969da;--stats-primary-text:#ffffff;--stats-success-bg:#2ea043;--stats-success-text:#ffffff;--stats-danger-bg:#f85149;--stats-danger-text:#ffffff;--stats-warning-bg:#fb8500;--stats-warning-text:#ffffff;--stats-info-bg:#0ea5e9;--stats-info-text:#ffffff}@media(prefers-color-scheme:dark){:root{--diff-bg-primary:#0d1117;--diff-bg-secondary:#161b22;--diff-bg-tertiary:#21262d;--diff-border-primary:#30363d;--diff-border-secondary:#21262d;--diff-text-primary:#f0f6fc;--diff-text-secondary:#8b949e;--diff-text-muted:#6e7681;--diff-text-accent:#58a6ff;--diff-addition-bg:#033a16;--diff-addition-border:#2ea043;--diff-addition-highlight:#196c2e;--diff-deletion-bg:#490202;--diff-deletion-border:#f85149;--diff-deletion-highlight:#c93c37;--diff-modification-bg:#341a00;--diff-modification-border:#fb8500;--diff-modification-highlight:#7d5200;--diff-shadow:0 1px 3px rgba(0,0,0,0.3);--diff-shadow-hover:0 2px 6px rgba(0,0,0,0.4);--stats-primary-bg:#1f6feb;--stats-success-bg:#238636;--stats-danger-bg:#da3633;--stats-warning-bg:#fb8500;--stats-info-bg:#0ea5e9}}.diff-container{background-color:var(--diff-bg-primary);border:1px solid var(--diff-border-primary);border-radius:8px;overflow:hidden;box-shadow:var(--diff-shadow);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Roboto',sans-serif;transition:box-shadow .2s ease-in-out}.diff-container:hover{box-shadow:var(--diff-shadow-hover)}.diff-container .diff-expand-notice{display:block;width:100%;text-align:center;border:none;border-top:1px solid var(--diff-border-primary);margin:0;background-color:var(--diff-bg-secondary);padding:10px 12px;color:var(--diff-text-secondary);font-size:13px;font-weight:500;cursor:pointer;transition:background-color .15s ease-in-out,color .15s ease-in-out}.diff-container .diff-expand-notice:hover{background-color:var(--diff-bg-tertiary);color:var(--diff-text-accent)}.diff-container .diff-expand-notice:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-container .diff-header{border-bottom:1px solid var(--diff-border-primary);background:linear-gradient(135deg,var(--diff-bg-secondary) 0%,var(--diff-bg-tertiary) 100%);border-top-left-radius:8px;border-top-right-radius:8px;display:flex;align-items:center;justify-content:space-between;min-height:50px;padding:0 16px}.diff-container .diff-footer{border-top:1px solid var(--diff-border-primary);background:linear-gradient(135deg,var(--diff-bg-secondary) 0%,var(--diff-bg-tertiary) 100%);border-bottom-left-radius:8px;border-bottom-right-radius:8px;display:flex;align-items:center;justify-content:space-between;min-height:50px;padding:0 16px}.diff-container .diff-panes{display:flex;background-color:var(--diff-bg-primary)}.diff-container .diff-panes .diff-pane{flex:1;border-right:1px solid var(--diff-border-primary)}.diff-container .diff-panes .diff-pane:last-child{border-right:none}.diff-container .diff-panes .diff-pane .line-text{word-break:break-all;word-wrap:break-word}.diff-stats-container{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}.diff-stats-badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:6px;font-size:12px;font-weight:500;line-height:1.2;white-space:nowrap;transition:all .2s ease-in-out;border:1px solid transparent}.diff-stats-badge:hover{transform:translateY(-1px);box-shadow:var(--diff-shadow)}.diff-stats-badge.primary{background-color:var(--stats-primary-bg);color:var(--stats-primary-text)}.diff-stats-badge.success{background-color:var(--stats-success-bg);color:var(--stats-success-text)}.diff-stats-badge.danger{background-color:var(--stats-danger-bg);color:var(--stats-danger-text)}.diff-stats-badge.warning{background-color:var(--stats-warning-bg);color:var(--stats-warning-text)}.diff-stats-badge.info{background-color:var(--stats-info-bg);color:var(--stats-info-text)}.diff-stats-badge .badge-icon{margin-right:4px;font-size:10px}.diff-pane-left{margin:0;padding:0;overflow:auto;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;font-size:14px;line-height:1.5}.diff-pane-left .line-number.modified{background-color:var(--diff-modification-bg);border-right:3px solid var(--diff-modification-border)}.diff-pane-left .line-number.deleted{background-color:var(--diff-deletion-bg);border-right:3px solid var(--diff-deletion-border)}.diff-pane-left .deleted-line{background-color:var(--diff-deletion-bg);border-left:3px solid var(--diff-deletion-border)}.diff-pane-left .modified-line{background-color:var(--diff-modification-bg);border-left:3px solid var(--diff-modification-border)}.diff-pane-right{margin:0;padding:0;overflow:auto;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;font-size:14px;line-height:1.5}.diff-pane-right .line-number.modified{background-color:var(--diff-modification-bg);border-right:3px solid var(--diff-modification-border)}.diff-pane-right .line-number.inserted{background-color:var(--diff-addition-bg);border-right:3px solid var(--diff-addition-border)}.diff-pane-right .modified-line{background-color:var(--diff-modification-bg);border-left:3px solid var(--diff-modification-border)}.diff-pane-right .inserted-line{background-color:var(--diff-addition-bg);border-left:3px solid var(--diff-addition-border)}.diff-pane-right .deleted-line{background-color:var(--diff-deletion-bg);border-left:3px solid var(--diff-deletion-border)}.diff-pane-right .inserted-word,.diff-pane-left .inserted-word{background-color:var(--diff-addition-bg);border-radius:var(--diff-word-radius);padding:var(--diff-word-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .modified-word,.diff-pane-left .modified-word{background-color:var(--diff-modification-bg);border-radius:var(--diff-word-radius);padding:var(--diff-word-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .deleted-word,.diff-pane-left .deleted-word{background-color:var(--diff-deletion-bg);border-radius:var(--diff-word-radius);padding:var(--diff-word-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .inserted-character,.diff-pane-left .inserted-character{background-color:var(--diff-addition-highlight);border-radius:var(--diff-char-radius);padding:var(--diff-char-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .modified-character,.diff-pane-left .modified-character{background-color:var(--diff-modification-highlight);border-radius:var(--diff-char-radius);padding:var(--diff-char-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.diff-pane-right .deleted-character,.diff-pane-left .deleted-character{background-color:var(--diff-deletion-highlight);border-radius:var(--diff-char-radius);padding:var(--diff-char-padding);box-decoration-break:clone;-webkit-box-decoration-break:clone}.inserted-character+.inserted-character,.modified-character+.modified-character,.deleted-character+.deleted-character{border-top-left-radius:0;border-bottom-left-radius:0;padding-left:0}.inserted-character:has(+.inserted-character),.modified-character:has(+.modified-character),.deleted-character:has(+.deleted-character){border-top-right-radius:0;border-bottom-right-radius:0;padding-right:0}table.diff{background-color:var(--diff-bg-primary);width:100%;border-collapse:collapse;margin:0;border-spacing:0}table.diff td.line-number{width:1%;min-width:60px;padding:4px 12px;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;font-size:12px;line-height:22px;color:var(--diff-text-muted);text-align:right;white-space:nowrap;vertical-align:top;cursor:pointer;user-select:none;background-color:var(--diff-bg-secondary);border-right:1px solid var(--diff-border-primary);font-weight:400;transition:color .2s ease-in-out,background-color .2s ease-in-out}table.diff td.line-number:hover{color:var(--diff-text-accent);background-color:var(--diff-bg-tertiary)}table.diff td.line{padding:4px 12px;line-height:22px;vertical-align:top;font-size:14px;font-family:'SFMono-Regular','Consolas','Liberation Mono','Menlo','Courier New',monospace;white-space:pre-wrap;word-wrap:break-word;color:var(--diff-text-primary)}table.diff td .unchanged-line{background-color:var(--diff-bg-primary)}table.diff tr:hover td.line-number{background-color:var(--diff-bg-tertiary)}table.diff tr:hover td.line{background-color:color-mix(in srgb,var(--diff-border-primary) 30%,transparent)}@media(max-width:768px){.diff-container .diff-panes{flex-direction:column}.diff-container .diff-panes .diff-pane{border-right:none;border-bottom:1px solid var(--diff-border-primary)}.diff-container .diff-panes .diff-pane:last-child{border-bottom:none}table.diff td.line-number{min-width:50px;padding:2px 8px;font-size:11px}table.diff td.line{padding:2px 8px;font-size:13px}.diff-stats-container{gap:4px}.diff-stats-badge{font-size:11px;padding:3px 6px}}@media(prefers-reduced-motion:reduce){.diff-container,.diff-stats-badge,table.diff td.line-number,table.diff td.line{transition:none}.diff-container .diff-header,.diff-container .diff-footer{background:var(--diff-bg-secondary)}}table.diff td.line-number:focus{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-stats-badge:focus{outline:2px solid var(--diff-text-accent);outline-offset:2px}@media print{.diff-container{box-shadow:none;border:1px solid #000}.diff-stats-badge{border:1px solid #000;background-color:transparent!important;color:#000!important}} -.diff-container .diff-panes.diff-scroll-panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.diff-container .diff-panes.diff-scroll-panes>.diff-pane{min-width:0;border-right:1px solid var(--diff-border-primary);border-bottom:none}.diff-container .diff-panes.diff-scroll-panes>.diff-pane:last-child{border-right:none}.diff-container .diff-scroll-pane{overflow-x:scroll;overflow-y:auto;scrollbar-gutter:stable;overscroll-behavior:contain}.diff-container .diff-scroll-pane:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-container .diff-scroll-pane table.diff{width:max-content;min-width:100%}.diff-container .diff-virtualized-pane table.diff .diff-row,.diff-container .diff-virtualized-pane table.diff td{height:var(--diff-virtual-row-height)}.diff-container .diff-virtualized-pane{overflow-anchor:none}.diff-container .diff-virtualized-pane table.diff td{box-sizing:border-box;padding:4px 12px;line-height:calc(var(--diff-virtual-row-height) - 8px)}.diff-container .diff-scroll-pane table.diff td.line-number{position:sticky;left:0;z-index:1;box-sizing:border-box;width:60px;min-width:60px}.diff-container .diff-scroll-pane table.diff td.line,.diff-container .diff-panes .diff-scroll-pane .line-text{white-space:pre;word-break:normal;overflow-wrap:normal} \ No newline at end of file +.diff-container .diff-panes.diff-scroll-panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.diff-container .diff-panes.diff-scroll-panes>.diff-pane{min-width:0;border-right:1px solid var(--diff-border-primary);border-bottom:none}.diff-container .diff-panes.diff-scroll-panes>.diff-pane:last-child{border-right:none}.diff-container .diff-scroll-pane{overflow-x:scroll;overflow-y:auto;scrollbar-gutter:stable;overscroll-behavior:contain}.diff-container .diff-scroll-pane:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:-2px}.diff-container .diff-scroll-pane table.diff{width:max-content;min-width:100%}.diff-container .diff-virtualized-pane table.diff .diff-row,.diff-container .diff-virtualized-pane table.diff td{height:var(--diff-virtual-row-height)}.diff-container .diff-virtualized-pane{overflow-anchor:none}.diff-container .diff-virtualized-pane table.diff td{box-sizing:border-box;padding:4px 12px;line-height:calc(var(--diff-virtual-row-height) - 8px)}.diff-container .diff-scroll-pane table.diff td.line-number{position:sticky;left:0;z-index:1;box-sizing:border-box;width:60px;min-width:60px}.diff-container .diff-scroll-pane table.diff td.line,.diff-container .diff-panes .diff-scroll-pane .line-text{white-space:pre;word-break:normal;overflow-wrap:normal} +.diff-container table.diff .diff-hidden-summary td{background-color:var(--diff-bg-tertiary);color:var(--diff-text-muted)}.diff-show-hidden{max-width:100%;margin:0;padding:0;border:0;background:none;color:var(--diff-text-accent);font:inherit;line-height:inherit;white-space:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.diff-show-hidden:hover{color:var(--diff-text-primary)}.diff-show-hidden:focus-visible{outline:2px solid var(--diff-text-accent);outline-offset:2px} \ No newline at end of file diff --git a/tests/BlazorTextDiff.Tests/TextDiffFoldingTests.cs b/tests/BlazorTextDiff.Tests/TextDiffFoldingTests.cs new file mode 100644 index 0000000..efc58eb --- /dev/null +++ b/tests/BlazorTextDiff.Tests/TextDiffFoldingTests.cs @@ -0,0 +1,283 @@ +using Bunit; +using DiffPlex.DiffBuilder.Model; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web.Virtualization; + +namespace BlazorTextDiff.Tests; + +public class TextDiffFoldingTests : TestContext +{ + public TextDiffFoldingTests() + { + var module = JSInterop.SetupModule("./_content/BlazorTextDiff/js/virtualizedDiff.js"); + var connection = module.SetupModule(call => call.Identifier == "connect"); + connection.SetupVoid("dispose").SetVoidResult(); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + public void HidingUnchangedLines_PreservesContextAndOriginalLineNumbers(bool virtualize, bool wrapLines) + { + var cut = RenderComparison(virtualize, wrapLines); + + cut.WaitForAssertion(() => Assert.Equal(4, cut.FindAll(".diff-hidden-summary").Count)); + Assert.Equal(10, cut.FindAll("tr.diff-row").Count); + Assert.Equal(new[] { "Show 3 unchanged lines", "Show 4 unchanged lines" }, + cut.FindAll(".diff-pane-left .diff-show-hidden").Select(button => button.TextContent)); + Assert.Equal(new[] { "Line 4", "Line 5", "Line 6" }, + cut.FindAll(".diff-pane-left .line-text").Select(line => line.TextContent)); + Assert.Equal(new[] { "Line 4", "Modified Line 5", "Line 6" }, + cut.FindAll(".diff-pane-right .line-text").Select(line => line.TextContent)); + Assert.Equal(new[] { "4", "5", "6" }, + cut.FindAll(".diff-pane-left tr:not(.diff-hidden-summary) .line-number") + .Select(number => number.TextContent.Trim())); + + var panes = cut.FindComponents(); + Assert.Same(panes[0].Instance.DisplayRows, panes[1].Instance.DisplayRows); + if (virtualize) + { + var virtualizers = cut.FindComponents>(); + Assert.Equal(2, virtualizers.Count); + Assert.All(virtualizers, component => + { + Assert.Same(panes[0].Instance.DisplayRows, component.Instance.Items); + Assert.Equal(30, component.Instance.ItemSize); + Assert.Equal("tr", component.Instance.SpacerElement); + }); + } + } + + [Theory] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public void RevealingABlock_UpdatesBothPanesWithoutRecomputing( + bool virtualize, bool wrapLines, bool clickRight) + { + var cut = RenderComparison(virtualize, wrapLines); + cut.WaitForAssertion(() => Assert.Equal(4, cut.FindAll(".diff-show-hidden").Count)); + var models = cut.FindComponents().Select(pane => pane.Instance.Model).ToArray(); + var firstSide = clickRight ? "right" : "left"; + var secondSide = clickRight ? "left" : "right"; + var focusTarget = cut.Find($".diff-pane-{firstSide}").GetAttribute("blazor:elementreference"); + Assert.False(string.IsNullOrEmpty(focusTarget)); + + cut.Find($".diff-pane-{firstSide} .diff-show-hidden").Click(); + + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".diff-hidden-summary").Count)); + Assert.Equal(14, cut.FindAll("tr.diff-row").Count); + Assert.All(cut.FindComponents(), pane => + Assert.Contains("Line 1", pane.FindAll(".line-text").Select(line => line.TextContent))); + var focus = JSInterop.VerifyFocusAsyncInvoke(); + Assert.Equal(focusTarget, Assert.IsType(focus.Arguments[0]).Id); + Assert.True(Assert.IsType(focus.Arguments[1])); + + cut.Find($".diff-pane-{secondSide} .diff-show-hidden").Click(); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".diff-hidden-summary"))); + Assert.Equal(20, cut.FindAll("tr.diff-row").Count); + Assert.Same(models[0], cut.FindComponents()[0].Instance.Model); + Assert.Same(models[1], cut.FindComponents()[1].Instance.Model); + } + + [Fact] + public void PresentationUpdates_KeepRevealedBlocksOpen() + { + var cut = RenderComparison(); + cut.Find(".diff-pane-left .diff-show-hidden").Click(); + var rows = cut.FindComponent().Instance.DisplayRows; + + cut.SetParametersAndRender(parameters => parameters + .Add(p => p.Class, "updated") + .Add(p => p.CollapseContent, true) + .Add(p => p.MaxHeight, 450)); + + Assert.Same(rows, cut.FindComponent().Instance.DisplayRows); + Assert.Equal(2, cut.FindAll(".diff-hidden-summary").Count); + Assert.Contains("Line 1", cut.FindAll(".diff-pane-left .line-text").Select(line => line.TextContent)); + } + + [Fact] + public void HidingAndContextOptions_UpdateVisibilityWithoutRecomputing() + { + var cut = RenderComparison(); + var model = cut.FindComponent().Instance.Model; + + cut.SetParametersAndRender(parameters => parameters.Add(p => p.ContextLines, 0)); + Assert.Equal(new[] { "Line 5" }, cut.FindAll(".diff-pane-left .line-text").Select(line => line.TextContent)); + Assert.Equal(new[] { "Show 4 unchanged lines", "Show 5 unchanged lines" }, + cut.FindAll(".diff-pane-left .diff-show-hidden").Select(button => button.TextContent)); + Assert.Same(model, cut.FindComponent().Instance.Model); + + cut.SetParametersAndRender(parameters => parameters.Add(p => p.ContextLines, int.MaxValue)); + Assert.Empty(cut.FindAll(".diff-hidden-summary")); + Assert.Equal(20, cut.FindAll(".line-text").Count); + Assert.Same(model, cut.FindComponent().Instance.Model); + + cut.SetParametersAndRender(parameters => parameters + .Add(p => p.HideUnchangedLines, false) + .Add(p => p.ContextLines, 1)); + Assert.Null(cut.FindComponent().Instance.DisplayRows); + Assert.Equal(20, cut.FindAll(".line-text").Count); + + cut.SetParametersAndRender(parameters => parameters.Add(p => p.HideUnchangedLines, true)); + Assert.Equal(4, cut.FindAll(".diff-hidden-summary").Count); + Assert.Same(model, cut.FindComponent().Instance.Model); + } + + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void NegativeContext_IsRejected(int contextLines) + { + Assert.Throws(() => + RenderComponent(parameters => parameters.Add(p => p.ContextLines, contextLines))); + } + + [Fact] + public void IdenticalTexts_RenderOneRevealableBlockPerPane() + { + var text = CreateText(10); + var cut = RenderComponent(parameters => parameters + .Add(p => p.OldText, text) + .Add(p => p.NewText, text) + .Add(p => p.HideUnchangedLines, true)); + + Assert.Equal(2, cut.FindAll(".diff-hidden-summary").Count); + Assert.Empty(cut.FindAll(".line-text")); + Assert.Equal("Show 10 unchanged lines", cut.Find(".diff-pane-left .diff-show-hidden").TextContent); + + cut.Find(".diff-pane-right .diff-show-hidden").Click(); + + Assert.Empty(cut.FindAll(".diff-hidden-summary")); + Assert.Equal(20, cut.FindAll(".line-text").Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EmptySide_KeepsAddedOrRemovedRowsVisible(bool emptyOld) + { + var cut = RenderComponent(parameters => parameters + .Add(p => p.OldText, emptyOld ? null : CreateText(3)) + .Add(p => p.NewText, emptyOld ? CreateText(3) : null) + .Add(p => p.HideUnchangedLines, true) + .Add(p => p.ContextLines, 0)); + + Assert.Empty(cut.FindAll(".diff-hidden-summary")); + Assert.Equal(6, cut.FindAll("tr.diff-row").Count); + } + + [Fact] + public void NewOrClearedTexts_ReplaceTheVisibilityState() + { + var cut = RenderComparison(); + cut.Find(".diff-show-hidden").Click(); + + cut.SetParametersAndRender(parameters => parameters + .Add(p => p.OldText, CreateText(3)) + .Add(p => p.NewText, CreateText(3))); + Assert.Equal(2, cut.FindAll(".diff-hidden-summary").Count); + Assert.Equal("Show 3 unchanged lines", cut.Find(".diff-show-hidden").TextContent); + + cut.SetParametersAndRender(parameters => parameters + .Add(p => p.OldText, string.Empty) + .Add(p => p.NewText, (string?)null)); + Assert.Empty(cut.FindComponents()); + } + + [Fact] + public void DeferredInputs_KeepTheComparisonButAllowVisibilityChanges() + { + var cut = RenderComparison(); + var model = cut.FindComponent().Instance.Model; + + cut.SetParametersAndRender(parameters => parameters + .Add(p => p.DeferDiff, true) + .Add(p => p.OldText, "Next original") + .Add(p => p.NewText, "Next modified") + .Add(p => p.ContextLines, 0)); + + Assert.Same(model, cut.FindComponent().Instance.Model); + Assert.Equal(new[] { "Line 5" }, cut.FindAll(".diff-pane-left .line-text").Select(line => line.TextContent)); + + cut.SetParametersAndRender(parameters => parameters.Add(p => p.DeferDiff, false)); + Assert.NotSame(model, cut.FindComponent().Instance.Model); + Assert.Empty(cut.FindAll(".diff-hidden-summary")); + Assert.Equal("Next original", cut.Find(".diff-pane-left .line-text").TextContent); + } + + [Fact] + public async Task StaleRevealEvent_DoesNotExpandAReplacementProjection() + { + var cut = RenderComparison(); + var pane = cut.FindComponent().Instance; + var oldRow = pane.DisplayRows!.First(row => row.IsHidden); + var callback = pane.OnShowHiddenLines; + + cut.SetParametersAndRender(parameters => parameters.Add(p => p.ContextLines, 0)); + await cut.InvokeAsync(() => callback.InvokeAsync(oldRow)); + + Assert.Equal(2, cut.FindAll(".line-text").Count); + Assert.Equal("Show 4 unchanged lines", cut.Find(".diff-pane-left .diff-show-hidden").TextContent); + } + + [Fact] + public void IgnoreOptionsAndStatistics_UseTheFullDiff() + { + RenderFragment header = stats => builder => + builder.AddContent(0, $"Modified: {stats.LineModificationCount}"); + var cut = RenderComponent(parameters => parameters + .Add(p => p.OldText, "Same\nHello World\nSame") + .Add(p => p.NewText, "Same\nhello World\nSame") + .Add(p => p.HideUnchangedLines, true) + .Add(p => p.ContextLines, 0) + .Add(p => p.Header, header)); + + Assert.Equal("Modified: 1", cut.Find(".diff-header").TextContent.Trim()); + Assert.Equal(2, cut.FindAll(".line-text").Count); + + cut.SetParametersAndRender(parameters => parameters + .Add(p => p.IgnoreCase, true) + .Add(p => p.IgnoreWhiteSpace, true)); + + Assert.Equal("Modified: 0", cut.Find(".diff-header").TextContent.Trim()); + Assert.Empty(cut.FindAll(".line-text")); + Assert.Equal("Show 3 unchanged lines", cut.Find(".diff-show-hidden").TextContent); + } + + [Fact] + public void ChangesAtBothEnds_KeepTheirContext() + { + var text = CreateText(10); + var cut = RenderComponent(parameters => parameters + .Add(p => p.OldText, text) + .Add(p => p.NewText, text.Replace("Line 1\n", "Changed 1\n").Replace("Line 10", "Changed 10")) + .Add(p => p.HideUnchangedLines, true) + .Add(p => p.ContextLines, 1)); + + Assert.Equal(new[] { "Line 1", "Line 2", "Line 9", "Line 10" }, + cut.FindAll(".diff-pane-left .line-text").Select(line => line.TextContent)); + Assert.Equal("Show 6 unchanged lines", cut.Find(".diff-pane-left .diff-show-hidden").TextContent); + } + + private IRenderedComponent RenderComparison(bool virtualize = false, bool wrapLines = true) + { + var text = CreateText(10); + return RenderComponent(parameters => parameters + .Add(p => p.OldText, text) + .Add(p => p.NewText, text.Replace("Line 5", "Modified Line 5")) + .Add(p => p.HideUnchangedLines, true) + .Add(p => p.ContextLines, 1) + .Add(p => p.Virtualize, virtualize) + .Add(p => p.WrapLines, wrapLines)); + } + + private static string CreateText(int count) => + string.Join("\n", Enumerable.Range(1, count).Select(index => $"Line {index}")); +}