diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb01ee..24db3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ body. Keep section headings exact and write notes in Markdown. ## [Unreleased] +### Added + +- **Markdown rendered-diff view.** When viewing `.md` or `.markdown` + files, a "Rendered" toggle in the toolbar switches between the + source-text diff (default Side-by-side / Inline editors) and a + rendered `FlowDocument` view that shows the diff *inside* the + rendered markdown. Removed text appears with red strikethrough, + added text with green background, word-level diff anchors on + unchanged tokens inside edited paragraphs (bold / italic / inline + code / hyperlink formatting preserved on those anchors), and links + whose URL changed but text didn't render in orange with a + both-URLs tooltip. Nested list items diff at item granularity. + Heavily-rewritten paragraphs (token similarity below 30%) fall + back to clean two-block tints instead of noisy interleaved word + fragments. Tables render as plain-text fallback. Rendered mode is + the default on first encounter; the user's toggle preference + persists across launches via the new `preferMarkdownRendered` + setting. F7 / F8 hunk navigation skips files viewed in rendered + mode (advancing to the next file) because the source-diff editors + and hunk overview bar are hidden in that mode. + ## [1.7.0] - 2026-06-05 ### Added diff --git a/DiffViewer.Tests/Rendering/MarkdownDiffRendererTests.cs b/DiffViewer.Tests/Rendering/MarkdownDiffRendererTests.cs new file mode 100644 index 0000000..99b59c5 --- /dev/null +++ b/DiffViewer.Tests/Rendering/MarkdownDiffRendererTests.cs @@ -0,0 +1,339 @@ +using System.Windows; +using System.Windows.Documents; +using System.Windows.Media; +using DiffViewer.Rendering; +using FluentAssertions; +using Xunit; + +namespace DiffViewer.Tests.Rendering; + +/// +/// Black-box tests for . The renderer +/// produces a WPF , so every test that touches +/// the output needs an STA apartment via [StaFact] +/// and its descendants (, , +/// ) are dispatcher-affine. +/// +/// Tests assert structure of the output (block kinds, inline runs, +/// brushes, text content) without reaching into the renderer's private +/// types — InternalsVisibleTo doesn't expose private +/// nested members anyway, and the contract under test is the public +/// shape of the . +/// +/// Sample matrix mirrors the 10 pairs from the spike's +/// samples/ folder so the integration's behavior on each +/// caveat-demonstration case stays in sync with the spike's verdict. +/// +public class MarkdownDiffRendererTests +{ + private static readonly Color DeletedBgColor = Color.FromRgb(0xFF, 0xE0, 0xE0); + private static readonly Color InsertedBgColor = Color.FromRgb(0xE0, 0xFF, 0xE0); + private static readonly Color UrlChangedFgColor = Color.FromRgb(0xCC, 0x66, 0x00); + + // ---------- baseline ---------- + + [StaFact] + public void Render_EmptyOldEmptyNew_ProducesEmptyDocument() + { + var doc = MarkdownDiffRenderer.Render("", ""); + doc.Should().NotBeNull(); + doc.Blocks.Should().BeEmpty(); + } + + [StaFact] + public void Render_IdenticalInputs_ProducesNoDiffDecorations() + { + const string md = "# Title\n\nA paragraph.\n"; + + var doc = MarkdownDiffRenderer.Render(md, md); + + // Should produce a heading + paragraph (plus possibly a trailing + // empty Paragraph from the trailing newline; not the contract + // under test). What IS the contract: nothing should be tinted. + foreach (var p in doc.Blocks.OfType()) + { + ColorOf(p.Background).Should().NotBe(DeletedBgColor); + ColorOf(p.Background).Should().NotBe(InsertedBgColor); + } + foreach (var run in AllRuns(doc)) + { + ColorOf(run.Background).Should().NotBe(DeletedBgColor); + ColorOf(run.Background).Should().NotBe(InsertedBgColor); + } + } + + // ---------- happy path ---------- + + [StaFact] + public void Render_HappyPath_ProducesHeadingPlusParagraphPlusList() + { + // Mirrors spike sample 01. + const string oldText = "# Title\n\nThe quick brown fox.\n\n- Alpha\n- Beta\n"; + const string newText = "# Title v2\n\nThe quick brown ferret.\n\n- Alpha\n- Beta\n- Gamma\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + doc.Blocks.OfType().Should().NotBeEmpty( + "the rendered output should include at least one block per source block"); + + // A new list item appended => somewhere in the document, either a + // full-block-tinted paragraph (Insert path) OR an insert-tinted + // Run (token path via short-block exemption) should appear. + bool anyInsertSignal = + doc.Blocks.OfType().Any(p => ColorOf(p.Background) == InsertedBgColor) + || AllRuns(doc).Any(r => ColorOf(r.Background) == InsertedBgColor); + + anyInsertSignal.Should().BeTrue( + "the new list item should appear with the insert tint somewhere in the output"); + } + + // ---------- caveat 1 fix: inline formatting preserved across diff ---------- + + [StaFact] + public void Render_InlineBoldEdit_PreservesBoldOnUnchangedTokens() + { + // Word-level edit inside a paragraph with bold text. + const string oldText = "Read the **important warning** below.\n"; + const string newText = "Read the **critical warning** below.\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // "warning" survives unchanged; the bold MUST be preserved on it. + var warningRun = AllRuns(doc).FirstOrDefault(r => r.Text == "warning"); + warningRun.Should().NotBeNull(); + warningRun!.FontWeight.Should().Be(FontWeights.Bold, + "the v2 token pipeline preserves inline formatting on unchanged sub-runs"); + } + + [StaFact] + public void Render_InlineCodeEdit_PreservesMonospaceOnChangedToken() + { + // Mirrors spike sample 03 idea: edit inside an inline-code span. + const string oldText = "Use `git status` to inspect.\n"; + const string newText = "Use `git status --short` to inspect.\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // The unchanged "git" token should retain monospace (Consolas). + var gitRun = AllRuns(doc).FirstOrDefault(r => r.Text == "git"); + gitRun.Should().NotBeNull(); + gitRun!.FontFamily.Source.Should().Be("Consolas", + "inline-code formatting carries through the token diff"); + } + + // ---------- link rendering ---------- + + [StaFact] + public void Render_LinkTextChange_RendersAsHyperlink() + { + const string oldText = "See the [home page](https://example.com).\n"; + const string newText = "See the [homepage](https://example.com).\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + var hyperlinks = AllInlines(doc).OfType().ToList(); + hyperlinks.Should().NotBeEmpty("link should render as a Hyperlink, not a plain Run"); + } + + [StaFact] + public void Render_LinkUrlOnlyChange_TintsHyperlinkOrange() + { + // Same link text, different URL -> orange tint per the spike's + // "honest about metadata changes" policy. + const string oldText = "See the [docs](https://example.com/v1).\n"; + const string newText = "See the [docs](https://example.com/v2).\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + var orange = AllInlines(doc).OfType().FirstOrDefault(h => + h.Foreground is SolidColorBrush b && b.Color == UrlChangedFgColor); + + orange.Should().NotBeNull( + "URL-only change should surface as the orange-tinted Hyperlink, not silently disappear"); + orange!.ToolTip.Should().NotBeNull("tooltip should disclose both URLs"); + orange.ToolTip!.ToString().Should().Contain("v1").And.Contain("v2"); + } + + // ---------- caveat 2 fix: nested list per-item diff ---------- + + [StaFact] + public void Render_NestedListInnerItemEdit_DiffsAtItemGranularity() + { + // Outer items "Vegetables" / "Fruits" identical; inner edit only. + const string oldText = "- Vegetables\n - Carrots\n - Spinach\n- Fruits\n - Apples\n"; + const string newText = "- Vegetables\n - Carrots\n - Broccoli\n- Fruits\n - Apples\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // "Carrots", "Apples" (and ideally "Vegetables", "Fruits") survive + // unchanged: their runs should NOT be tinted. + bool carrotsClean = AllRuns(doc).Where(r => r.Text == "Carrots").All(IsClean); + bool applesClean = AllRuns(doc).Where(r => r.Text == "Apples").All(IsClean); + bool vegetablesClean = AllRuns(doc).Where(r => r.Text == "Vegetables").All(IsClean); + bool fruitsClean = AllRuns(doc).Where(r => r.Text == "Fruits").All(IsClean); + + carrotsClean.Should().BeTrue("a sibling item should not be marked as changed when only Spinach->Broccoli was edited"); + applesClean.Should().BeTrue("the unedited fruit should not be marked as changed"); + vegetablesClean.Should().BeTrue("the outer item should not be marked as changed when only an inner item was edited"); + fruitsClean.Should().BeTrue("the second outer item should not be marked as changed"); + + // The actual edit IS visible somewhere. + bool spinachStruck = AllRuns(doc).Any(r => + r.Text == "Spinach" && r.Background is SolidColorBrush b && b.Color == DeletedBgColor); + bool broccoliInserted = AllRuns(doc).Any(r => + r.Text == "Broccoli" && r.Background is SolidColorBrush b && b.Color == InsertedBgColor); + + spinachStruck.Should().BeTrue("the deleted item should carry the delete tint"); + broccoliInserted.Should().BeTrue("the inserted item should carry the insert tint"); + } + + // ---------- known limitation: tables fall back to plain text ---------- + + [StaFact] + public void Render_TableCellChange_FallsBackToPlainTextBlock() + { + const string oldText = "| H |\n|---|\n| a |\n"; + const string newText = "| H |\n|---|\n| b |\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // Plain-text fallback means no WPF Table element in the output. + doc.Blocks.OfType().Should().BeEmpty( + "tables intentionally render as plain-text Paragraphs in this version"); + } + + // ---------- caveat 3c fix: similarity gate prevents word-diff noise ---------- + + [StaFact] + public void Render_HeavyRewriteWithNoSharedWords_FallsBackToTwoFullBlockTints() + { + // Same heading anchors the position; the paragraph has zero token + // overlap so similarity gate (MinReplaceSimilarity = 0.30) should + // refuse to coalesce into a Replace. + const string oldText = "## Lookups\n\nUse indexes on frequently queried columns to speed up lookups.\n"; + const string newText = "## Lookups\n\nMaterialized views shine when aggregations rarely change.\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // Similarity-gate fallback => the deleted paragraph and the inserted + // paragraph each render as a SEPARATE block with the tint on the + // PARAGRAPH (not on its descendant Runs). + int redParagraphs = doc.Blocks.OfType().Count(p => + ColorOf(p.Background) == DeletedBgColor); + int greenParagraphs = doc.Blocks.OfType().Count(p => + ColorOf(p.Background) == InsertedBgColor); + + redParagraphs.Should().BeGreaterOrEqualTo(1, + "the deleted paragraph should be a single full-block red tint, not interleaved word fragments"); + greenParagraphs.Should().BeGreaterOrEqualTo(1, + "the inserted paragraph should be a single full-block green tint, not interleaved word fragments"); + + // And there should be NO single paragraph that contains both a + // deleted-Run and an inserted-Run — that would mean the + // coalesce-into-Replace happened anyway. + bool anyMixedParagraph = doc.Blocks.OfType().Any(p => + { + var runs = AllRunsIn(p).ToList(); + bool hasDeleteRun = runs.Any(r => ColorOf(r.Background) == DeletedBgColor); + bool hasInsertRun = runs.Any(r => ColorOf(r.Background) == InsertedBgColor); + return hasDeleteRun && hasInsertRun; + }); + anyMixedParagraph.Should().BeFalse( + "similarity gate should keep delete and insert in SEPARATE paragraphs for zero-overlap rewrites"); + } + + [StaFact] + public void Render_SmallListItemReplace_StaysCoalescedViaShortBlockExemption() + { + // "Spinach" vs "Broccoli" share zero tokens but are below the + // short-block-exemption threshold, so they should still coalesce + // into a single tidy inline Replace. + const string oldText = "- Spinach\n"; + const string newText = "- Broccoli\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // Output is one list item paragraph with one strike Run + one + // insert Run (in addition to the bullet prefix Run). + doc.Blocks.OfType().Should().HaveCount(1, + "short-block exemption keeps a single-token Replace as one inline line, not split into two list rows"); + + var runs = AllRunsIn(doc.Blocks.OfType().Single()).ToList(); + runs.Any(r => r.Text == "Spinach" && r.Background is SolidColorBrush b && b.Color == DeletedBgColor) + .Should().BeTrue(); + runs.Any(r => r.Text == "Broccoli" && r.Background is SolidColorBrush b && b.Color == InsertedBgColor) + .Should().BeTrue(); + } + + // ---------- caveat 3a known limitation: move detection ---------- + + [StaFact] + public void Render_ReorderedSections_RenderAsSeparateDeleteAndInsert() + { + // Three sections, last two swapped. No move-detection in LCS, so + // this renders as delete + insert separated by the unchanged + // middle. Test pins this behavior so a future move-detection + // change is intentional, not silent. + const string oldText = "# T\n\n## A\nFirst.\n\n## B\nSecond.\n\n## C\nThird.\n"; + const string newText = "# T\n\n## A\nFirst.\n\n## C\nThird.\n\n## B\nSecond.\n"; + + var doc = MarkdownDiffRenderer.Render(oldText, newText); + + // Full-block tints from Delete/Insert ops land on the Paragraph, + // not on its descendant Runs. + bool anyDeleteParagraph = doc.Blocks.OfType() + .Any(p => ColorOf(p.Background) == DeletedBgColor); + bool anyInsertParagraph = doc.Blocks.OfType() + .Any(p => ColorOf(p.Background) == InsertedBgColor); + + anyDeleteParagraph.Should().BeTrue( + "moved section's old position should produce at least one full-block-tinted delete paragraph"); + anyInsertParagraph.Should().BeTrue( + "moved section's new position should produce at least one full-block-tinted insert paragraph"); + } + + // ---------- helpers ---------- + + private static IEnumerable AllRuns(FlowDocument doc) => + doc.Blocks.SelectMany(AllRunsIn); + + private static IEnumerable AllRunsIn(Block block) + { + if (block is Paragraph p) + return AllInlinesUnder(p.Inlines).OfType(); + if (block is Section s) + return s.Blocks.SelectMany(AllRunsIn); + return Array.Empty(); + } + + private static IEnumerable AllInlines(FlowDocument doc) => + doc.Blocks.SelectMany(AllInlinesIn); + + private static IEnumerable AllInlinesIn(Block block) + { + if (block is Paragraph p) return AllInlinesUnder(p.Inlines); + if (block is Section s) return s.Blocks.SelectMany(AllInlinesIn); + return Array.Empty(); + } + + private static IEnumerable AllInlinesUnder(InlineCollection inlines) + { + foreach (var inl in inlines) + { + yield return inl; + if (inl is Span span) + { + foreach (var child in AllInlinesUnder(span.Inlines)) + yield return child; + } + } + } + + private static Color? ColorOf(Brush? brush) => + brush is SolidColorBrush scb ? scb.Color : (Color?)null; + + private static bool IsClean(Run run) => + run.Background is null + || (ColorOf(run.Background) != DeletedBgColor + && ColorOf(run.Background) != InsertedBgColor); +} diff --git a/DiffViewer.Tests/Services/SettingsServiceTests.cs b/DiffViewer.Tests/Services/SettingsServiceTests.cs index 8ac3a93..11f6943 100644 --- a/DiffViewer.Tests/Services/SettingsServiceTests.cs +++ b/DiffViewer.Tests/Services/SettingsServiceTests.cs @@ -588,6 +588,44 @@ public void Save_Then_Load_RoundTripsSkippedUpdateVersion() svc2.Current.SkippedUpdateVersion.Should().Be("1.5.0-rc1"); } + [Fact] + public void Load_V8File_MigratesToV9_PreferMarkdownRenderedDefaultsToTrue() + { + // v8 schema (current minus 1) had no PreferMarkdownRendered + // field. After v8->v9 migration the field should hydrate to + // its default (true) and other fields should be preserved. + // Same migration shape as v5->v6 added RenderSvgImage. + var v8 = new JsonObject + { + ["schemaVersion"] = 8, + ["fontSize"] = 22, + ["renderSvgImage"] = false, + ["isSideBySide"] = false, + }; + File.WriteAllText(_settingsPath, v8.ToJsonString()); + + var svc = new SettingsService(_settingsPath); + + svc.LastLoadOutcome.Should().Be(SettingsLoadOutcome.Migrated); + svc.Current.FontSize.Should().Be(22); + svc.Current.RenderSvgImage.Should().BeFalse(); + svc.Current.IsSideBySide.Should().BeFalse(); + svc.Current.PreferMarkdownRendered.Should().BeTrue( + "default for the new field, hydrated when missing from a pre-v9 file"); + } + + [Fact] + public void Save_Then_Load_RoundTripsPreferMarkdownRendered() + { + var svc1 = new SettingsService(_settingsPath); + svc1.Save(svc1.Current with { PreferMarkdownRendered = false }); + + var svc2 = new SettingsService(_settingsPath); + + svc2.LastLoadOutcome.Should().Be(SettingsLoadOutcome.Loaded); + svc2.Current.PreferMarkdownRendered.Should().BeFalse(); + } + [Fact] public void RepoUrlMappings_StableOrderingOnDisk_AcrossSaves() { diff --git a/DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs b/DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs index 5cc1771..c64022b 100644 --- a/DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs +++ b/DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs @@ -421,6 +421,332 @@ await RunOnUiSyncContextAsync(async () => }); } + // ---- Markdown rendered-diff dispatch ---- + + [Fact] + public async Task LoadAsync_MarkdownFile_SetsIsMarkdownFileAndBuildsRenderedVm() + { + // A .md file rides the text-diff path AND gets a MarkdownDiff + // VM layered on top, so the toggle has both surfaces available. + var repo = new FakeRepository + { + LeftText = "# Title\n\nA paragraph.\n", + RightText = "# Title v2\n\nA changed paragraph.\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeTrue(); + vm.MarkdownDiff.Should().NotBeNull("text-dispatch ContinueWith builds the rendered VM on the UI thread"); + vm.RenderMarkdownRendered.Should().BeTrue("default is rendered (mirrors RenderSvgImage default)"); + vm.ShowMarkdownRenderedToggle.Should().BeTrue(); + vm.ShowMarkdownRendered.Should().BeTrue("rendered surface should take over by default"); + vm.ShowEditors.Should().BeFalse("rendered surface hides the source editors"); + vm.LeftDocument.Text.Should().NotBeEmpty( + "source text is still loaded so the toggle flip is instant"); + }); + } + + [Fact] + public async Task LoadAsync_MarkdownExtensionVariant_AlsoDetected() + { + // .markdown is the long-form spelling; same dispatch. + var repo = new FakeRepository + { + LeftText = "Old.\n", + RightText = "New.\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("CHANGES.markdown"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeTrue(); + vm.MarkdownDiff.Should().NotBeNull(); + }); + } + + [Fact] + public async Task LoadAsync_MarkdownExtensionCaseInsensitive() + { + var repo = new FakeRepository + { + LeftText = "a\n", + RightText = "b\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("Readme.MD"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeTrue(); + }); + } + + [Fact] + public async Task LoadAsync_NonMarkdownFile_LeavesMarkdownStateClear() + { + var repo = new FakeRepository + { + LeftText = "alpha\n", + RightText = "beta\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("notes.txt"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeFalse(); + vm.MarkdownDiff.Should().BeNull(); + vm.ShowMarkdownRenderedToggle.Should().BeFalse(); + vm.ShowMarkdownRendered.Should().BeFalse(); + vm.ShowEditors.Should().BeTrue(); + }); + } + + [Fact] + public async Task RenderMarkdownRendered_TogglingOff_FlipsToSourceWithoutLosingVm() + { + var repo = new FakeRepository + { + LeftText = "# Title\n", + RightText = "# Title v2\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + + vm.ShowMarkdownRendered.Should().BeTrue(); + int readCountAfterLoad = repo.ReadCount; + + vm.RenderMarkdownRendered = false; + + vm.ShowMarkdownRendered.Should().BeFalse("toggling off must hide the rendered view"); + vm.ShowEditors.Should().BeTrue("source editors take over the surface"); + vm.MarkdownDiff.Should().NotBeNull( + "the rendered VM is retained for instant flip-back, not torn down"); + repo.ReadCount.Should().Be(readCountAfterLoad, + "toggling must not re-read blobs"); + + vm.RenderMarkdownRendered = true; + + vm.ShowMarkdownRendered.Should().BeTrue(); + vm.ShowEditors.Should().BeFalse(); + }); + } + + [Fact] + public async Task LoadAsync_TextFileAfterMarkdown_ResetsMarkdownFlags() + { + var repo = new FakeRepository + { + LeftText = "# Title\n", + RightText = "# Title v2\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + vm.IsMarkdownFile.Should().BeTrue(); + vm.MarkdownDiff.Should().NotBeNull(); + + repo.LeftText = "alpha\n"; + repo.RightText = "beta\n"; + await vm.LoadAsync(Entry(ModifiedTextFile("notes.txt"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeFalse(); + vm.MarkdownDiff.Should().BeNull(); + vm.ShowMarkdownRenderedToggle.Should().BeFalse(); + vm.ShowMarkdownRendered.Should().BeFalse(); + vm.ShowEditors.Should().BeTrue(); + }); + } + + [Fact] + public async Task LoadAsync_NullEntryAfterMarkdown_ClearsMarkdownState() + { + var repo = new FakeRepository + { + LeftText = "# T\n", + RightText = "# T v2\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + vm.IsMarkdownFile.Should().BeTrue(); + + await vm.LoadAsync(null); + + vm.IsMarkdownFile.Should().BeFalse(); + vm.MarkdownDiff.Should().BeNull(); + }); + } + + [Fact] + public async Task TryNavigateNextHunkInFile_ReturnsFalse_WhenInMarkdownRenderedMode() + { + // Hunk-nav targets the AvalonEdit editors which are hidden in + // rendered mode. Returning false lets the cross-file orchestrator + // advance to the next file. + var repo = new FakeRepository + { + LeftText = "# Title\n\nOriginal text here.\n", + RightText = "# Title\n\nChanged text here.\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + + vm.ShowMarkdownRendered.Should().BeTrue(); + vm.TryNavigateNextHunkInFile().Should().BeFalse( + "rendered mode hides the editors so F7 should advance to next file"); + vm.TryNavigatePreviousHunkInFile().Should().BeFalse(); + + // Toggle out of rendered mode -> hunk nav becomes available again. + vm.RenderMarkdownRendered = false; + vm.ShowMarkdownRendered.Should().BeFalse(); + // We don't assert TryNavigate* returns TRUE here because that + // depends on the file having hunks and the caret state; the + // important contract under test is the rendered-mode GATE. + }); + } + + [Fact] + public async Task LoadAsync_BinaryFile_ClearsMarkdownState() + { + // Binary dispatch is an early branch; markdown flags must not + // leak through. + var repo = new FakeRepository + { + LeftText = "# T\n", + RightText = "# T v2\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + vm.IsMarkdownFile.Should().BeTrue(); + + await vm.LoadAsync(Entry(Binary("blob.bin"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeFalse(); + vm.MarkdownDiff.Should().BeNull(); + }); + } + + [Fact] + public async Task RenderMarkdownRendered_Toggling_DoesNotChangeIsMarkdownFileFlag() + { + // IsMarkdownFile is identity (extension); RenderMarkdownRendered + // is user preference. They are independent. + var repo = new FakeRepository + { + LeftText = "# T\n", + RightText = "# T v2\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + + vm.IsMarkdownFile.Should().BeTrue(); + vm.RenderMarkdownRendered = false; + vm.IsMarkdownFile.Should().BeTrue("the file is still markdown; only the view preference flipped"); + vm.ShowMarkdownRenderedToggle.Should().BeTrue("toggle stays visible when MarkdownDiff is non-null"); + }); + } + + [Fact] + public async Task LoadAsync_MarkdownFile_RaisesShowEditorsPropertyChanged() + { + // Critical for the visibility cascade: loading a markdown file + // must fire OnPropertyChanged(ShowEditors) so the source-editors + // grid actually collapses when the rendered VM appears. + var repo = new FakeRepository + { + LeftText = "# T\n", + RightText = "# T v2\n", + }; + + await RunOnUiSyncContextAsync(async () => + { + var vm = new DiffPaneViewModel(repo, new DiffService()); + var fired = new HashSet(); + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName is not null) fired.Add(e.PropertyName); + }; + + await vm.LoadAsync(Entry(ModifiedTextFile("README.md"))); + await vm.LastLoadTask; + + fired.Should().Contain(nameof(DiffPaneViewModel.ShowEditors)); + fired.Should().Contain(nameof(DiffPaneViewModel.ShowMarkdownRendered)); + fired.Should().Contain(nameof(DiffPaneViewModel.ShowMarkdownRenderedToggle)); + fired.Should().Contain(nameof(DiffPaneViewModel.IsMarkdownFile)); + fired.Should().Contain(nameof(DiffPaneViewModel.MarkdownDiff)); + }); + } + + [Fact] + public async Task LoadAsync_SvgFile_DoesNotSetMarkdownFlagsEvenIfPathLooksMarkdownLike() + { + // SVG dispatch is checked before markdown detection; an SVG file + // with a confusing path (e.g. shipped through the .svg path) must + // not accidentally also set IsMarkdownFile. + var repo = new FakeRepository + { + LeftText = "", + RightText = "", + }; + var decoder = new FakeImageDecoder(); + + await RunOnUiSyncContextAsync(async () => + { + decoder.DecodeFunc = (_, _) => new ImageDecodeResult( + MakeFrozenBitmap(2, 2), + new ImageMetadata(2, 2, 6, ImageFormat.Svg, 1), + null); + + var vm = new DiffPaneViewModel(repo, new DiffService(), imageDecoder: decoder); + await vm.LoadAsync(Entry(ModifiedTextFile("icon.svg"))); + await vm.LastLoadTask; + + vm.IsSvgFile.Should().BeTrue(); + vm.IsMarkdownFile.Should().BeFalse("SVG branch should not also flag as markdown"); + vm.MarkdownDiff.Should().BeNull(); + }); + } + + // ---- Markdown rendered-diff dispatch (end) ---- + [Fact] public async Task RenderSvgImage_TogglePersistsToSettings() { @@ -455,6 +781,38 @@ public void RenderSvgImage_ExternalSettingsChange_PushesIntoViewModel() vm.RenderSvgImage.Should().BeFalse(); } + [Fact] + public void RenderMarkdownRendered_TogglePersistsToSettings() + { + // Same persistence shape as RenderSvgImage above. Flipping the + // VM's RenderMarkdownRendered must write through to settings. + var repo = new FakeRepository(); + var settings = new InMemorySettingsServiceForPane(new AppSettings { PreferMarkdownRendered = true }); + var vm = new DiffPaneViewModel(repo, settingsService: settings); + + vm.RenderMarkdownRendered.Should().BeTrue("seeded from settings.PreferMarkdownRendered"); + + vm.RenderMarkdownRendered = false; + settings.Current.PreferMarkdownRendered.Should().BeFalse(); + + vm.RenderMarkdownRendered = true; + settings.Current.PreferMarkdownRendered.Should().BeTrue(); + } + + [Fact] + public void RenderMarkdownRendered_ExternalSettingsChange_PushesIntoViewModel() + { + // External settings change (e.g. from another window or a + // settings file edit) must propagate into the VM. + var repo = new FakeRepository(); + var settings = new InMemorySettingsServiceForPane(new AppSettings { PreferMarkdownRendered = true }); + var vm = new DiffPaneViewModel(repo, settingsService: settings); + + settings.Save(settings.Current with { PreferMarkdownRendered = false }); + + vm.RenderMarkdownRendered.Should().BeFalse(); + } + [Fact] public async Task LoadAsync_LfsPointer_ShowsLfsPlaceholder() { diff --git a/DiffViewer.Tests/ViewModels/MarkdownDiffViewModelTests.cs b/DiffViewer.Tests/ViewModels/MarkdownDiffViewModelTests.cs new file mode 100644 index 0000000..6873c58 --- /dev/null +++ b/DiffViewer.Tests/ViewModels/MarkdownDiffViewModelTests.cs @@ -0,0 +1,64 @@ +using DiffViewer.ViewModels; +using FluentAssertions; +using Xunit; + +namespace DiffViewer.Tests.ViewModels; + +/// +/// Unit tests for . The VM is a thin +/// wrapper around ; +/// renderer behavior is covered by MarkdownDiffRendererTests, so +/// these tests focus on the VM's contract — construction succeeds, the +/// resulting Document is non-null, and degenerate inputs don't +/// throw. +/// +/// Uses [StaFact] because the constructor builds a +/// dispatcher-affine . +/// +public class MarkdownDiffViewModelTests +{ + [StaFact] + public void Ctor_PopulatesDocumentFromBothSides() + { + var vm = new MarkdownDiffViewModel( + leftText: "# Title\n", + rightText: "# Title v2\n"); + + vm.Document.Should().NotBeNull(); + vm.Document.Blocks.Should().NotBeEmpty(); + } + + [StaFact] + public void Ctor_WithIdenticalSides_StillProducesADocument() + { + const string md = "# Same\n\nSame paragraph.\n"; + + var vm = new MarkdownDiffViewModel(md, md); + + vm.Document.Should().NotBeNull(); + vm.Document.Blocks.Should().NotBeEmpty(); + } + + [StaFact] + public void Ctor_WithEmptyStrings_DoesNotThrow() + { + // Edge: a newly-added or deleted markdown file lands here with + // one side empty; the renderer should handle that and so should + // the VM. + var vm = new MarkdownDiffViewModel("", ""); + + vm.Document.Should().NotBeNull(); + } + + [StaFact] + public void Ctor_WithNullCoercedToEmpty_DoesNotThrow() + { + // DiffPaneViewModel passes _cachedLeftText / _cachedRightText + // which can legitimately be empty strings; the VM accepts null + // defensively and coerces to empty so a misuse doesn't crash + // the load path. + var vm = new MarkdownDiffViewModel(null!, null!); + + vm.Document.Should().NotBeNull(); + } +} diff --git a/DiffViewer/DiffViewer.csproj b/DiffViewer/DiffViewer.csproj index e0b67a7..9b72208 100644 --- a/DiffViewer/DiffViewer.csproj +++ b/DiffViewer/DiffViewer.csproj @@ -114,6 +114,7 @@ + diff --git a/DiffViewer/Models/AppSettings.cs b/DiffViewer/Models/AppSettings.cs index 5898b83..eb92ac1 100644 --- a/DiffViewer/Models/AppSettings.cs +++ b/DiffViewer/Models/AppSettings.cs @@ -16,7 +16,7 @@ namespace DiffViewer.Models; public sealed record AppSettings { /// Current schema version; bump every time the shape changes. - public const int CurrentSchemaVersion = 8; + public const int CurrentSchemaVersion = 9; public int SchemaVersion { get; init; } = CurrentSchemaVersion; @@ -61,6 +61,19 @@ public sealed record AppSettings /// public bool RenderSvgImage { get; init; } = true; + /// + /// Markdown-only "Rendered" toolbar toggle (added in v9). When + /// true, the diff pane shows the rendered + /// diff of the + /// markdown file; when false, the source-text diff in the + /// AvalonEdit editors. Defaults to true for the same reason + /// as : rendered diffing is the + /// headline feature, and the user opted in by opening a markdown + /// file. Has no effect for non-markdown files; the toolbar hides + /// the toggle in that case. + /// + public bool PreferMarkdownRendered { get; init; } = true; + // ---- File-list display mode ---- public FileListDisplayMode DisplayMode { get; init; } = FileListDisplayMode.RepoRelative; diff --git a/DiffViewer/Rendering/MarkdownDiffRenderer.cs b/DiffViewer/Rendering/MarkdownDiffRenderer.cs new file mode 100644 index 0000000..deb5f37 --- /dev/null +++ b/DiffViewer/Rendering/MarkdownDiffRenderer.cs @@ -0,0 +1,1090 @@ +using System.Text; +using System.Windows; +using System.Windows.Documents; +using System.Windows.Media; +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using MdBlock = Markdig.Syntax.Block; +using MdInline = Markdig.Syntax.Inlines.Inline; +using MdContainerInline = Markdig.Syntax.Inlines.ContainerInline; +using MdTable = Markdig.Extensions.Tables.Table; +using WpfBlock = System.Windows.Documents.Block; +using WpfInline = System.Windows.Documents.Inline; + +namespace DiffViewer.Rendering; + +/// +/// Turns two raw markdown strings into a single +/// that renders both, with red/green decoration on the differences. +/// +/// Threading: UI-thread only. and +/// its descendants (, , +/// , etc.) are +/// s with thread +/// affinity to the thread that created them. Construct the result on the +/// dispatcher and bind to it from the same dispatcher. The parsing / +/// diffing work is pure CPU and could be moved to a background thread in +/// the future, but the assembly itself must +/// run on the UI thread. +/// +/// Pipeline: +/// +/// Parse both blobs with Markdig. +/// Unfold each document into a flat sequence of block units +/// (top-level blocks, plus per-item entries for list items at any depth). +/// Hand-rolled LCS on the (kind, text) keys to produce a sequence +/// of Equal/Delete/Insert ops. +/// Coalesce adjacent same-kind Delete + Insert pairs into Replace +/// when token-LCS similarity passes the gate, so paragraphs with +/// inline-only edits show word-by-word diff instead of two red/green +/// block tints. +/// Render each op to one or more FlowDocument blocks. Replace ops +/// produce a single paragraph with token-level word-diff that preserves +/// inline formatting (bold/italic/code/hyperlinks) on unchanged +/// sub-runs. +/// +/// +/// Lifted verbatim from +/// spikes/MarkdownDiffSpike/MarkdownDiffRenderer.cs with only the +/// namespace and one missing .Freeze() call changed; see +/// spikes/MarkdownDiffSpike/FINDINGS.md on the spike branch for +/// the verdict and the four caveats (move detection, context-blind keys, +/// similarity threshold, table fallback). +/// +internal static class MarkdownDiffRenderer +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + private static readonly Brush DeletedBg = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0xE0)); + private static readonly Brush InsertedBg = new SolidColorBrush(Color.FromRgb(0xE0, 0xFF, 0xE0)); + private static readonly Brush DeletedFg = new SolidColorBrush(Color.FromRgb(0xA0, 0x00, 0x00)); + private static readonly Brush InsertedFg = new SolidColorBrush(Color.FromRgb(0x00, 0x50, 0x00)); + private static readonly Brush CodeBg = new SolidColorBrush(Color.FromRgb(0xF4, 0xF4, 0xF4)); + private static readonly Brush HrBrush = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)); + private static readonly Brush QuoteBorder = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)); + + static MarkdownDiffRenderer() + { + DeletedBg.Freeze(); InsertedBg.Freeze(); + DeletedFg.Freeze(); InsertedFg.Freeze(); + CodeBg.Freeze(); HrBrush.Freeze(); QuoteBorder.Freeze(); + UrlChangedFg.Freeze(); + } + + public static FlowDocument Render(string oldText, string newText) + { + var oldDoc = Markdown.Parse(oldText, Pipeline); + var newDoc = Markdown.Parse(newText, Pipeline); + + var oldBlocks = Unfold(oldDoc); + var newBlocks = Unfold(newDoc); + + var ops = Diff(oldBlocks, newBlocks); + ops = CoalesceReplaces(ops); + + var fd = new FlowDocument + { + FontFamily = new FontFamily("Segoe UI"), + FontSize = 14, + PagePadding = new Thickness(24, 16, 24, 16), + ColumnWidth = double.PositiveInfinity, + }; + + foreach (var op in ops) + { + foreach (var block in RenderOp(op)) + fd.Blocks.Add(block); + } + + return fd; + } + + // ---------- unfolding ---------- + + private enum BlockKind { Heading, Paragraph, Code, ListItem, Quote, ThematicBreak, Table, Unknown } + + /// + /// One unit of diff. is the original Markdig block + /// (for rendering); is the comparison key for LCS. + /// For list items we capture extra ordering context so the renderer can + /// reconstruct bullet/number prefixes. + /// + private sealed record DiffBlock(string Key, BlockKind Kind, MdBlock Source, ListContext? List = null); + + private sealed record ListContext(bool Ordered, int Index, int StartFrom, int Depth); + + private static List Unfold(MarkdownDocument doc) + { + var result = new List(); + foreach (var block in doc) + UnfoldBlock(block, result); + return result; + } + + private static void UnfoldBlock(MdBlock block, List sink) + { + switch (block) + { + case HeadingBlock h: + sink.Add(new DiffBlock( + Key: $"h{h.Level}|{Normalize(InlineText(h.Inline))}", + Kind: BlockKind.Heading, + Source: h)); + break; + + case ParagraphBlock p: + sink.Add(new DiffBlock( + Key: $"p|{Normalize(InlineText(p.Inline))}", + Kind: BlockKind.Paragraph, + Source: p)); + break; + + case FencedCodeBlock fc: + sink.Add(new DiffBlock( + Key: $"code|{fc.Info}|{Normalize(LeafContent(fc))}", + Kind: BlockKind.Code, + Source: fc)); + break; + + case CodeBlock cb: + sink.Add(new DiffBlock( + Key: $"code||{Normalize(LeafContent(cb))}", + Kind: BlockKind.Code, + Source: cb)); + break; + + case ListBlock list: + UnfoldList(list, depth: 0, sink); + break; + + case QuoteBlock quote: + sink.Add(new DiffBlock( + Key: $"q|{Normalize(BlockText(quote))}", + Kind: BlockKind.Quote, + Source: quote)); + break; + + case ThematicBreakBlock tb: + sink.Add(new DiffBlock(Key: "hr", Kind: BlockKind.ThematicBreak, Source: tb)); + break; + + case MdTable table: + sink.Add(new DiffBlock( + Key: $"table|{Normalize(BlockText(table))}", + Kind: BlockKind.Table, + Source: table)); + break; + + default: + sink.Add(new DiffBlock( + Key: $"u|{block.GetType().Name}|{Normalize(BlockText(block))}", + Kind: BlockKind.Unknown, + Source: block)); + break; + } + } + + private static string Normalize(string s) + { + var sb = new StringBuilder(s.Length); + bool prevWs = false; + foreach (char c in s) + { + bool ws = char.IsWhiteSpace(c); + if (ws) + { + if (!prevWs) sb.Append(' '); + prevWs = true; + } + else + { + sb.Append(c); + prevWs = false; + } + } + return sb.ToString().Trim(); + } + + /// + /// Unfolds a (possibly nested) ListBlock into a flat sequence of + /// per-item DiffBlocks. Each item contributes one DiffBlock keyed on + /// its own first-paragraph text (NOT its descendants). Nested ListBlock + /// children produce their own DiffBlocks immediately after the parent, + /// at depth+1. This lets the diff algorithm match inner and outer + /// changes independently, instead of treating any inner edit as a + /// whole-outer-item rewrite. + /// + private static void UnfoldList(ListBlock list, int depth, List sink) + { + int i = 0; + int startFrom = list.IsOrdered && int.TryParse(list.OrderedStart, out int s) ? s : 1; + foreach (var item in list) + { + if (item is not ListItemBlock lib) continue; + var ctx = new ListContext(list.IsOrdered, i, startFrom, depth); + + // Own-text key uses only the first paragraph child, so a change + // to a nested item does not invalidate the outer item's match. + string ownText = Normalize(ItemOwnText(lib)); + sink.Add(new DiffBlock( + Key: $"li|{(list.IsOrdered ? "o" : "u")}|d{depth}|{ownText}", + Kind: BlockKind.ListItem, + Source: lib, + List: ctx)); + + // Recurse into any nested ListBlocks. Other child block types + // (extra paragraphs, code blocks, blockquotes inside an item) + // are out of spike scope and silently dropped from the diff + // view, matching the prior behaviour for multi-paragraph items. + foreach (var child in lib) + { + if (child is ListBlock nested) + UnfoldList(nested, depth + 1, sink); + } + + i++; + } + } + + private static string ItemOwnText(ListItemBlock lib) + { + var first = lib.OfType().FirstOrDefault(); + return first?.Inline is null ? string.Empty : InlineText(first.Inline); + } + + // ---------- LCS diff ---------- + + private abstract record Op; + private sealed record EqualOp(DiffBlock Old, DiffBlock New) : Op; + private sealed record DeleteOp(DiffBlock Old) : Op; + private sealed record InsertOp(DiffBlock New) : Op; + private sealed record ReplaceOp(DiffBlock Old, DiffBlock New) : Op; + + private static List Diff(List oldBlocks, List newBlocks) + { + int n = oldBlocks.Count, m = newBlocks.Count; + + // Standard dynamic-programming LCS table. + var dp = new int[n + 1, m + 1]; + for (int i = n - 1; i >= 0; i--) + { + for (int j = m - 1; j >= 0; j--) + { + dp[i, j] = oldBlocks[i].Key == newBlocks[j].Key + ? dp[i + 1, j + 1] + 1 + : Math.Max(dp[i + 1, j], dp[i, j + 1]); + } + } + + var ops = new List(); + int oi = 0, ni = 0; + while (oi < n && ni < m) + { + if (oldBlocks[oi].Key == newBlocks[ni].Key) + { + ops.Add(new EqualOp(oldBlocks[oi], newBlocks[ni])); + oi++; ni++; + } + else if (dp[oi + 1, ni] >= dp[oi, ni + 1]) + { + ops.Add(new DeleteOp(oldBlocks[oi])); + oi++; + } + else + { + ops.Add(new InsertOp(newBlocks[ni])); + ni++; + } + } + while (oi < n) ops.Add(new DeleteOp(oldBlocks[oi++])); + while (ni < m) ops.Add(new InsertOp(newBlocks[ni++])); + return ops; + } + + /// + /// Walks the op stream and pairs adjacent runs of Delete+Insert of + /// matching kind into Replace ops. Limited to paragraphs, headings, + /// and list-items — the kinds where inline-level diff is meaningful. + /// Excess on either side falls back to plain Delete / Insert. + /// + private static List CoalesceReplaces(List ops) + { + var output = new List(ops.Count); + int i = 0; + while (i < ops.Count) + { + int delStart = i; + while (i < ops.Count && ops[i] is DeleteOp) i++; + int delEnd = i; + int insStart = i; + while (i < ops.Count && ops[i] is InsertOp) i++; + int insEnd = i; + + int delCount = delEnd - delStart; + int insCount = insEnd - insStart; + + if (delCount == 0 && insCount == 0) + { + output.Add(ops[i]); + i++; + continue; + } + + // Greedy: pair Delete[k] with Insert[k] if they share a kind + // we care about AND have enough token overlap to make + // word-diff readable. Below threshold, fall back to plain + // Delete + Insert so the user sees a clean two-block "old + // replaced with new" view instead of alternating red/green + // word fragments. Anything left over from either run stays + // as-is. + int paired = 0; + int maxPair = Math.Min(delCount, insCount); + for (int k = 0; k < maxPair; k++) + { + var d = (DeleteOp)ops[delStart + k]; + var n = (InsertOp)ops[insStart + k]; + if (IsReplaceCandidate(d.Old, n.New) && PassesReplaceSimilarity(d.Old, n.New)) + { + output.Add(new ReplaceOp(d.Old, n.New)); + paired = k + 1; + } + else + { + break; + } + } + for (int k = paired; k < delCount; k++) output.Add(ops[delStart + k]); + for (int k = paired; k < insCount; k++) output.Add(ops[insStart + k]); + } + return output; + } + + private static bool IsReplaceCandidate(DiffBlock oldBlock, DiffBlock newBlock) + { + if (oldBlock.Kind != newBlock.Kind) return false; + return oldBlock.Kind is BlockKind.Paragraph or BlockKind.Heading or BlockKind.ListItem; + } + + // ---------- replace similarity gate ---------- + + /// + /// Fraction of tokens (on the longer side) that must match between + /// the two blocks for the coalesce step to produce a Replace pair. + /// Below this, the pair stays as separate Delete + Insert ops so the + /// renderer emits clean full-block tinted blocks instead of + /// alternating red/green word fragments. 0.30 is a defensible + /// starting point — high enough that nearly-rewritten paragraphs + /// fall to full-block rendering, low enough that a paragraph with + /// 1 word edited out of 4 still gets word-diff. Empirical tuning + /// is a real-integration concern, not a spike one. + /// + private const double MinReplaceSimilarity = 0.30; + + /// + /// Short blocks (max non-whitespace token count strictly less than + /// this) bypass the similarity gate entirely and always coalesce. + /// Justification: a Replace of two single-word list items + /// ("Spinach" -> "Broccoli") renders as one tidy + /// "[strike]Spinach Broccoli" line; splitting it into two list + /// rows would lose the "this is one swap" framing for no visual + /// gain. The noise the gate exists to prevent only kicks in once a + /// block is long enough to make alternating fragments hard to + /// read. + /// + private const int ShortBlockExemption = 5; + + private static bool PassesReplaceSimilarity(DiffBlock oldBlock, DiffBlock newBlock) + { + var oldTokens = TokenizeBlock(oldBlock).Where(t => !IsWhitespaceText(t.Text)).ToList(); + var newTokens = TokenizeBlock(newBlock).Where(t => !IsWhitespaceText(t.Text)).ToList(); + + int maxLen = Math.Max(oldTokens.Count, newTokens.Count); + if (maxLen < ShortBlockExemption) return true; + + // Empty (with the other side non-empty) means zero overlap on a + // long block. Don't coalesce. + if (oldTokens.Count == 0 || newTokens.Count == 0) return false; + + int lcs = ComputeTokenLcsLength(oldTokens, newTokens); + return (double)lcs / maxLen >= MinReplaceSimilarity; + } + + private static bool IsWhitespaceText(string s) => + !string.IsNullOrEmpty(s) && s.All(char.IsWhiteSpace); + + /// + /// LCS length over a non-whitespace token sequence. Equality is the + /// same (text + format) check the render-time token diff uses, so + /// the similarity score and the render output stay consistent — + /// the gate doesn't claim "match" for a pair the render will then + /// fail to anchor. + /// + private static int ComputeTokenLcsLength(List oldT, List newT) + { + int n = oldT.Count, m = newT.Count; + var dp = new int[n + 1, m + 1]; + for (int i = n - 1; i >= 0; i--) + { + for (int j = m - 1; j >= 0; j--) + { + dp[i, j] = TokensMatch(oldT[i], newT[j]) + ? dp[i + 1, j + 1] + 1 + : Math.Max(dp[i + 1, j], dp[i, j + 1]); + } + } + return dp[0, 0]; + } + + // ---------- rendering ---------- + + private enum Mode { Normal, Deleted, Inserted } + + private static IEnumerable RenderOp(Op op) => op switch + { + // For text-eligible kinds, route Equal pairs through the token-diff + // path too — that way URL-only changes inside an otherwise-Equal + // hyperlink (text identical, URL differs) get the orange tint + + // tooltip from BuildTokenInline, instead of silently rendering as + // a normal blue hyperlink with the new URL. + EqualOp e when IsTokenEligible(e.New.Kind) => RenderReplace(e.Old, e.New), + EqualOp e => RenderBlockShape(e.New, Mode.Normal), + DeleteOp d => RenderBlockShape(d.Old, Mode.Deleted), + InsertOp i => RenderBlockShape(i.New, Mode.Inserted), + ReplaceOp r => RenderReplace(r.Old, r.New), + _ => Array.Empty(), + }; + + private static bool IsTokenEligible(BlockKind kind) => + kind is BlockKind.Paragraph or BlockKind.Heading or BlockKind.ListItem; + + private static IEnumerable RenderBlockShape(DiffBlock db, Mode mode) + { + switch (db.Kind) + { + case BlockKind.Heading: + { + var h = (HeadingBlock)db.Source; + var headingPara = new Paragraph + { + FontSize = HeadingSize(h.Level), + FontWeight = FontWeights.Bold, + Margin = new Thickness(0, 12, 0, 6), + }; + foreach (var inl in BuildInlinesFrom(h.Inline, mode)) + headingPara.Inlines.Add(inl); + yield return Decorate(headingPara, mode, fullBlock: true); + break; + } + + case BlockKind.Paragraph: + { + var p = (ParagraphBlock)db.Source; + var paraPara = new Paragraph + { + Margin = new Thickness(0, 4, 0, 4), + }; + foreach (var inl in BuildInlinesFrom(p.Inline, mode)) + paraPara.Inlines.Add(inl); + yield return Decorate(paraPara, mode, fullBlock: true); + break; + } + + case BlockKind.Code: + { + string content = LeafContent((LeafBlock)db.Source); + string? lang = db.Source is FencedCodeBlock fc ? fc.Info : null; + var para = new Paragraph + { + FontFamily = new FontFamily("Consolas"), + FontSize = 12, + Background = mode == Mode.Normal ? CodeBg : BackgroundFor(mode), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 6, 0, 6), + }; + if (!string.IsNullOrEmpty(lang)) + { + var langRun = new Run("[" + lang + "]\n") + { + FontSize = 10, + Foreground = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)), + }; + para.Inlines.Add(langRun); + } + var bodyRun = new Run(content); + if (mode == Mode.Deleted) bodyRun.TextDecorations = TextDecorations.Strikethrough; + para.Inlines.Add(bodyRun); + yield return para; + break; + } + + case BlockKind.ListItem: + { + var lib = (ListItemBlock)db.Source; + var ctx = db.List; + string prefix = ctx is { Ordered: true } + ? $"{ctx.StartFrom + ctx.Index}. " + : "• "; + + var para = new Paragraph + { + Margin = new Thickness(20 + (ctx?.Depth ?? 0) * 24, 2, 0, 2), + TextIndent = -16, + }; + para.Inlines.Add(new Run(prefix) { FontWeight = FontWeights.Normal }); + + // The DiffBlock keys/diffs against this item's OWN + // first-paragraph text only (see UnfoldList). Nested + // ListBlock children are emitted as separate DiffBlocks + // and render below this one with their own depth context, + // so we deliberately do not recurse into them here. + ParagraphBlock? firstPara = lib.OfType().FirstOrDefault(); + if (firstPara is not null) + { + foreach (var inl in BuildInlinesFrom(firstPara.Inline, mode)) + para.Inlines.Add(inl); + } + yield return Decorate(para, mode, fullBlock: true); + break; + } + + case BlockKind.Quote: + { + var qb = (QuoteBlock)db.Source; + foreach (var child in qb) + { + if (child is ParagraphBlock cp) + { + var para = new Paragraph + { + BorderBrush = QuoteBorder, + BorderThickness = new Thickness(3, 0, 0, 0), + Padding = new Thickness(8, 2, 0, 2), + Margin = new Thickness(0, 4, 0, 4), + Foreground = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + }; + foreach (var inl in BuildInlinesFrom(cp.Inline, mode)) + para.Inlines.Add(inl); + yield return Decorate(para, mode, fullBlock: true); + } + else + { + // Nested non-paragraph content in a blockquote falls + // back to plain text — spike limitation. + yield return Decorate(new Paragraph(new Run(BlockText(child))), mode, fullBlock: true); + } + } + break; + } + + case BlockKind.ThematicBreak: + { + yield return new BlockUIContainer(new System.Windows.Controls.Border + { + BorderBrush = HrBrush, + BorderThickness = new Thickness(0, 0, 0, 1), + Margin = new Thickness(0, 8, 0, 8), + }); + break; + } + + case BlockKind.Table: + { + // Plain-text fallback. Documented in FINDINGS.md. + var para = new Paragraph + { + FontFamily = new FontFamily("Consolas"), + FontSize = 12, + Margin = new Thickness(0, 6, 0, 6), + }; + para.Inlines.Add(new Run(BlockText(db.Source))); + yield return Decorate(para, mode, fullBlock: true); + break; + } + + default: + { + yield return Decorate(new Paragraph(new Run(BlockText(db.Source))), mode, fullBlock: true); + break; + } + } + } + + private static double HeadingSize(int level) => level switch + { + 1 => 26, 2 => 22, 3 => 19, 4 => 17, 5 => 15, _ => 14, + }; + + private static WpfBlock Decorate(Paragraph p, Mode mode, bool fullBlock) + { + if (mode == Mode.Normal) return p; + if (fullBlock) + { + p.Background = BackgroundFor(mode); + if (mode == Mode.Deleted) + { + p.TextDecorations = TextDecorations.Strikethrough; + p.Foreground = DeletedFg; + } + else + { + p.Foreground = InsertedFg; + } + } + return p; + } + + private static Brush BackgroundFor(Mode mode) => + mode == Mode.Deleted ? DeletedBg : InsertedBg; + + // ---------- inline (in-paragraph) rendering ---------- + + private static IEnumerable BuildInlinesFrom(MdContainerInline? container, Mode mode) + { + if (container is null) yield break; + foreach (var inl in container) + { + foreach (var wpf in BuildInline(inl, mode)) + yield return wpf; + } + } + + private static IEnumerable BuildInline(MdInline inline, Mode mode) + { + switch (inline) + { + case LiteralInline lit: + yield return new Run(lit.Content.ToString()); + break; + + case EmphasisInline em: + { + var span = new Span(); + foreach (var child in em) + foreach (var wpf in BuildInline(child, mode)) + span.Inlines.Add(wpf); + // Markdig encodes ** as DelimiterCount==2; * as 1. + if (em.DelimiterCount >= 2) span.FontWeight = FontWeights.Bold; + else span.FontStyle = FontStyles.Italic; + yield return span; + break; + } + + case CodeInline code: + yield return new Run(code.Content) + { + FontFamily = new FontFamily("Consolas"), + FontSize = 12, + Background = CodeBg, + }; + break; + + case LinkInline link: + { + // Render link text. For URL-only changes (#04 sample), + // visible text is unchanged — by design, we don't fake a + // visible difference. Honest about what's rendered. + var hyper = new Hyperlink + { + NavigateUri = TryUri(link.Url), + ToolTip = link.Url, + }; + foreach (var child in link) + foreach (var wpf in BuildInline(child, mode)) + hyper.Inlines.Add(wpf); + yield return hyper; + break; + } + + case LineBreakInline: + yield return new LineBreak(); + break; + + case AutolinkInline auto: + yield return new Hyperlink(new Run(auto.Url)) + { + NavigateUri = TryUri(auto.Url), + }; + break; + + default: + // Fall back to whatever text content the inline exposes. + yield return new Run(inline is MdContainerInline ci ? InlineText(ci) : inline.ToString() ?? string.Empty); + break; + } + } + + private static Uri? TryUri(string? url) => + !string.IsNullOrEmpty(url) && Uri.TryCreate(url, UriKind.Absolute, out var u) ? u : null; + + // ---------- replace = format-preserving token-level inline diff ---------- + + /// + /// Renders a Replace pair as a single paragraph with token-level + /// (word-granularity) diff. Improvement over the v1 flatten-to-text + /// approach: each token carries its original inline formatting + /// (bold / italic / inline-code / hyperlink), so unchanged sub-runs + /// of a changed paragraph keep their original rendering. Hyperlink + /// URL changes inside an otherwise-Equal token surface as an orange + /// tint plus a tooltip — the rendered text is honestly unchanged, + /// but the metadata difference is visible enough to notice. + /// + private static IEnumerable RenderReplace(DiffBlock oldBlock, DiffBlock newBlock) + { + var oldTokens = TokenizeBlock(oldBlock); + var newTokens = TokenizeBlock(newBlock); + var ops = DiffTokens(oldTokens, newTokens); + var inlines = RenderTokenOps(ops).ToList(); + + switch (oldBlock.Kind) + { + case BlockKind.Heading: + { + var h = (HeadingBlock)newBlock.Source; + var para = new Paragraph + { + FontSize = HeadingSize(h.Level), + FontWeight = FontWeights.Bold, + Margin = new Thickness(0, 12, 0, 6), + }; + foreach (var inl in inlines) para.Inlines.Add(inl); + yield return para; + break; + } + + case BlockKind.ListItem: + { + var ctx = newBlock.List; + string prefix = ctx is { Ordered: true } + ? $"{ctx.StartFrom + ctx.Index}. " + : "• "; + var para = new Paragraph + { + Margin = new Thickness(20 + (ctx?.Depth ?? 0) * 24, 2, 0, 2), + TextIndent = -16, + }; + para.Inlines.Add(new Run(prefix)); + foreach (var inl in inlines) para.Inlines.Add(inl); + yield return para; + break; + } + + default: // Paragraph + { + var para = new Paragraph { Margin = new Thickness(0, 4, 0, 4) }; + foreach (var inl in inlines) para.Inlines.Add(inl); + yield return para; + break; + } + } + } + + // ---------- tokenization ---------- + + /// + /// Inline-formatting bits a token carries through the diff. Stored as + /// flags so a token can be (bold + italic + code) etc. Used both as + /// part of the token-equality key and to drive render-time decoration. + /// + [Flags] + private enum InlineFormat + { + None = 0, + Bold = 1, + Italic = 2, + Code = 4, + } + + /// + /// One word (or one whitespace run) extracted from a paragraph's + /// inline tree, plus the formatting context it was rendered under. + /// is non-null when the token was inside a + /// markdown link — see the URL-change handling in + /// . + /// + private sealed record InlineToken(string Text, InlineFormat Format, string? LinkUrl); + + private static List TokenizeBlock(DiffBlock db) + { + var tokens = new List(); + switch (db.Source) + { + case LeafBlock lb when lb.Inline is not null: + TokenizeInline(lb.Inline, InlineFormat.None, null, tokens); + break; + case ListItemBlock lib: + { + var first = lib.OfType().FirstOrDefault(); + if (first?.Inline is not null) + TokenizeInline(first.Inline, InlineFormat.None, null, tokens); + break; + } + } + return tokens; + } + + private static void TokenizeInline(MdInline inline, InlineFormat format, string? linkUrl, List sink) + { + switch (inline) + { + case LiteralInline lit: + foreach (var word in SplitWords(lit.Content.ToString())) + sink.Add(new InlineToken(word, format, linkUrl)); + break; + + case CodeInline code: + foreach (var word in SplitWords(code.Content)) + sink.Add(new InlineToken(word, format | InlineFormat.Code, linkUrl)); + break; + + case EmphasisInline em: + { + // Markdig encodes ** (bold) as DelimiterCount==2; * (italic) + // as 1. We OR into the inherited format so nested + // bold+italic survives. + var childFormat = format | (em.DelimiterCount >= 2 ? InlineFormat.Bold : InlineFormat.Italic); + foreach (var child in em) + TokenizeInline(child, childFormat, linkUrl, sink); + break; + } + + case LinkInline link: + foreach (var child in link) + TokenizeInline(child, format, link.Url, sink); + break; + + case LineBreakInline: + sink.Add(new InlineToken(" ", format, linkUrl)); + break; + + case AutolinkInline auto: + sink.Add(new InlineToken(auto.Url, format, auto.Url)); + break; + + case MdContainerInline cont: + foreach (var child in cont) + TokenizeInline(child, format, linkUrl, sink); + break; + } + } + + /// + /// Splits text into alternating runs of non-whitespace and whitespace. + /// Round-tripping all runs concatenated reproduces the input exactly, + /// so word-level diffing without losing space placement just works. + /// Treats CLI-flag-style tokens ("--short") as one word, which is + /// usually what the reader wants. + /// + private static IEnumerable SplitWords(string text) + { + if (string.IsNullOrEmpty(text)) yield break; + int start = 0; + bool inWs = char.IsWhiteSpace(text[0]); + for (int i = 1; i < text.Length; i++) + { + bool ws = char.IsWhiteSpace(text[i]); + if (ws != inWs) + { + yield return text.Substring(start, i - start); + start = i; + inWs = ws; + } + } + yield return text.Substring(start); + } + + // ---------- token diff (LCS) ---------- + + private abstract record TokenOp; + private sealed record TokenEqual(InlineToken Old, InlineToken New) : TokenOp; + private sealed record TokenDelete(InlineToken Token) : TokenOp; + private sealed record TokenInsert(InlineToken Token) : TokenOp; + + private static List DiffTokens(List oldT, List newT) + { + int n = oldT.Count, m = newT.Count; + var dp = new int[n + 1, m + 1]; + for (int i = n - 1; i >= 0; i--) + { + for (int j = m - 1; j >= 0; j--) + { + dp[i, j] = TokensMatch(oldT[i], newT[j]) + ? dp[i + 1, j + 1] + 1 + : Math.Max(dp[i + 1, j], dp[i, j + 1]); + } + } + + var ops = new List(); + int oi = 0, ni = 0; + while (oi < n && ni < m) + { + if (TokensMatch(oldT[oi], newT[ni])) + { + ops.Add(new TokenEqual(oldT[oi], newT[ni])); + oi++; ni++; + } + else if (dp[oi + 1, ni] >= dp[oi, ni + 1]) + { + ops.Add(new TokenDelete(oldT[oi++])); + } + else + { + ops.Add(new TokenInsert(newT[ni++])); + } + } + while (oi < n) ops.Add(new TokenDelete(oldT[oi++])); + while (ni < m) ops.Add(new TokenInsert(newT[ni++])); + return ops; + } + + /// + /// Equality used by the token LCS: text + formatting bits match, URL + /// is ignored. A token whose only difference is its URL still pairs + /// as Equal; the URL change is surfaced at render time (tint + tooltip) + /// instead of as a delete+insert pair. + /// + private static bool TokensMatch(InlineToken a, InlineToken b) => + a.Text == b.Text && a.Format == b.Format; + + // ---------- token rendering ---------- + + private static IEnumerable RenderTokenOps(IEnumerable ops) + { + foreach (var op in ops) + { + switch (op) + { + case TokenEqual eq: + yield return BuildTokenInline(eq.New, Mode.Normal, oldUrl: eq.Old.LinkUrl); + break; + case TokenDelete del: + yield return BuildTokenInline(del.Token, Mode.Deleted, oldUrl: null); + break; + case TokenInsert ins: + yield return BuildTokenInline(ins.Token, Mode.Inserted, oldUrl: null); + break; + } + } + } + + private static readonly Brush UrlChangedFg = new SolidColorBrush(Color.FromRgb(0xCC, 0x66, 0x00)); + + private static WpfInline BuildTokenInline(InlineToken t, Mode mode, string? oldUrl) + { + var run = new Run(t.Text); + + // Format-derived styling first; diff-derived styling layers on top. + if ((t.Format & InlineFormat.Bold) != 0) run.FontWeight = FontWeights.Bold; + if ((t.Format & InlineFormat.Italic) != 0) run.FontStyle = FontStyles.Italic; + bool isCode = (t.Format & InlineFormat.Code) != 0; + if (isCode) + { + run.FontFamily = new FontFamily("Consolas"); + run.FontSize = 12; + } + + // Diff backgrounds overwrite the code background — the diff signal + // is more important than the "this is inline code" cue, and the + // monospace + bold still distinguish a deleted/inserted code token + // from surrounding prose. + if (mode == Mode.Deleted) + { + run.Background = DeletedBg; + run.Foreground = DeletedFg; + run.TextDecorations = TextDecorations.Strikethrough; + } + else if (mode == Mode.Inserted) + { + run.Background = InsertedBg; + run.Foreground = InsertedFg; + } + else if (isCode) + { + run.Background = CodeBg; + } + + if (t.LinkUrl is null) return run; + + var hyper = new Hyperlink(run) { NavigateUri = TryUri(t.LinkUrl) }; + bool urlChanged = oldUrl is not null && oldUrl != t.LinkUrl; + if (urlChanged) + { + // Tint the link orange so URL-only changes are visible at a + // glance, not buried in a tooltip. Honest disclosure that + // something changed even though the rendered text didn't. + hyper.Foreground = UrlChangedFg; + hyper.ToolTip = $"URL changed.\nNow: {t.LinkUrl}\nWas: {oldUrl}"; + } + else + { + hyper.ToolTip = t.LinkUrl; + } + return hyper; + } + + // ---------- text extraction (still used by Unfold for block keys) ---------- + + private static string InlineText(MdContainerInline? container) + { + if (container is null) return string.Empty; + var sb = new StringBuilder(); + AppendInlineText(container, sb); + return sb.ToString(); + } + + private static void AppendInlineText(MdInline inline, StringBuilder sb) + { + switch (inline) + { + case LiteralInline lit: sb.Append(lit.Content.ToString()); break; + case CodeInline code: sb.Append(code.Content); break; + case AutolinkInline auto: sb.Append(auto.Url); break; + case LineBreakInline: sb.Append(' '); break; + case MdContainerInline container: + foreach (var child in container) + AppendInlineText(child, sb); + break; + } + } + + private static string LeafContent(LeafBlock lb) + { + if (lb.Lines.Count == 0) return string.Empty; + var sb = new StringBuilder(); + for (int i = 0; i < lb.Lines.Count; i++) + { + if (i > 0) sb.Append('\n'); + sb.Append(lb.Lines.Lines[i].ToString()); + } + return sb.ToString(); + } + + private static string BlockText(MdBlock block) + { + var sb = new StringBuilder(); + AppendBlockText(block, sb); + return sb.ToString().Trim(); + } + + private static void AppendBlockText(MdBlock block, StringBuilder sb) + { + switch (block) + { + case LeafBlock lb when lb.Inline is not null: + AppendInlineText(lb.Inline, sb); + sb.Append('\n'); + break; + case LeafBlock lb: + sb.Append(LeafContent(lb)); + sb.Append('\n'); + break; + case ContainerBlock cb: + foreach (var child in cb) + AppendBlockText(child, sb); + break; + } + } +} diff --git a/DiffViewer/Services/SettingsJsonSerializer.cs b/DiffViewer/Services/SettingsJsonSerializer.cs index 498d927..e08ff6d 100644 --- a/DiffViewer/Services/SettingsJsonSerializer.cs +++ b/DiffViewer/Services/SettingsJsonSerializer.cs @@ -38,6 +38,7 @@ public static string Serialize(AppSettings s) ["liveUpdates"] = s.LiveUpdates, ["sideVisibility"] = s.SideVisibility.ToString(), ["renderSvgImage"] = s.RenderSvgImage, + ["preferMarkdownRendered"] = s.PreferMarkdownRendered, ["displayMode"] = s.DisplayMode.ToString(), ["largeFileThresholdBytes"] = s.LargeFileThresholdBytes, ["fontFamily"] = s.FontFamily, @@ -76,6 +77,7 @@ public static AppSettings Deserialize(JsonObject obj) LiveUpdates = TryBool(obj, "liveUpdates") ?? defaults.LiveUpdates, SideVisibility = TryEnum(obj, "sideVisibility") ?? defaults.SideVisibility, RenderSvgImage = TryBool(obj, "renderSvgImage") ?? defaults.RenderSvgImage, + PreferMarkdownRendered = TryBool(obj, "preferMarkdownRendered") ?? defaults.PreferMarkdownRendered, DisplayMode = TryEnum(obj, "displayMode") ?? defaults.DisplayMode, LargeFileThresholdBytes = TryLong(obj, "largeFileThresholdBytes") ?? defaults.LargeFileThresholdBytes, FontFamily = TryString(obj, "fontFamily") ?? defaults.FontFamily, diff --git a/DiffViewer/Services/SettingsMigrations.cs b/DiffViewer/Services/SettingsMigrations.cs index 5ab53a7..404db7a 100644 --- a/DiffViewer/Services/SettingsMigrations.cs +++ b/DiffViewer/Services/SettingsMigrations.cs @@ -53,6 +53,14 @@ namespace DiffViewer.Services; /// default — pre-v8 files deserialize to "nothing skipped", which /// means the banner shows for every available update (the safe /// default). +/// +/// v9 introduced preferMarkdownRendered for the +/// markdown-only "Rendered" toolbar toggle (the markdown rendered-diff +/// feature). The migration is a no-op because the field has a +/// sensible default (true) — pre-v9 files deserialize with the +/// rendered view enabled by default, matching the headline behaviour +/// of the feature (same shape as the v6 renderSvgImage +/// migration). /// internal static class SettingsMigrations { @@ -76,6 +84,7 @@ public static JsonObject MigrateUpTo(JsonObject obj, int fromVersion, int toVers 5 => MigrateV5ToV6, // adds renderSvgImage (bool, default true) 6 => MigrateV6ToV7, // adds autoUpdate / updateCheckCadence / includePreReleases (all defaultable) 7 => MigrateV7ToV8, // adds skippedUpdateVersion (nullable string, default null) + 8 => MigrateV8ToV9, // adds preferMarkdownRendered (bool, default true) _ => throw new InvalidOperationException($"No migration registered from version {v} to {v + 1}."), }; current = step(current); @@ -153,4 +162,14 @@ public static JsonObject MigrateUpTo(JsonObject obj, int fromVersion, int toVers /// the banner shows for every available update. /// private static JsonObject MigrateV7ToV8(JsonObject obj) => obj; + + /// + /// v9 adds preferMarkdownRendered for the markdown-only + /// "Rendered" toolbar toggle. A missing field means "rendered + /// view" (the headline behaviour of the markdown rendered-diff + /// feature) so the migration is a no-op; the deserializer fills + /// in as + /// true. + /// + private static JsonObject MigrateV8ToV9(JsonObject obj) => obj; } diff --git a/DiffViewer/ViewModels/DiffPaneViewModel.cs b/DiffViewer/ViewModels/DiffPaneViewModel.cs index d99683e..45d8822 100644 --- a/DiffViewer/ViewModels/DiffPaneViewModel.cs +++ b/DiffViewer/ViewModels/DiffPaneViewModel.cs @@ -168,7 +168,7 @@ private readonly record struct LoadSignature( /// public bool ShowImageDiffModeControls => ShowImageDiff && ImageDiff is not null && ImageDiff.HasBothImages; public bool ShowPlaceholder => PlaceholderMessage is not null && !ShowImageDiff; - public bool ShowEditors => !ShowImageDiff && !ShowPlaceholder; + public bool ShowEditors => !ShowImageDiff && !ShowPlaceholder && !ShowMarkdownRendered; public bool ShowSideBySide => ShowEditors && IsSideBySide; public bool ShowInline => ShowEditors && !IsSideBySide; @@ -207,6 +207,51 @@ private readonly record struct LoadSignature( /// public bool ShowSvgTextView => IsSvgFile && (!RenderSvgImage || !IsSvgRenderable); + // ---- Markdown rendered-diff (issue: markdown-rendered-diff) ---- + + /// + /// true when the currently-loaded entry is a markdown file + /// (.md / .markdown) by extension. Set during + /// . Drives the markdown "Rendered" toolbar + /// toggle and its dependent visibility gates. Independent of + /// : a true value means + /// "this is a markdown file"; the user's choice of source vs + /// rendered view is a separate flag. + /// + [ObservableProperty] private bool _isMarkdownFile; + + /// + /// Markdown rendered-diff sibling view-model. Non-null when the + /// currently loaded entry has been dispatched to the markdown + /// rendered pane; null for non-markdown, placeholder, and + /// pre-load states. Set on the UI thread after the blob reads + /// complete because the contained is + /// dispatcher-affine. + /// + [ObservableProperty] private MarkdownDiffViewModel? _markdownDiff; + + /// + /// Whether the markdown "Rendered" toolbar toggle should be shown. + /// True only when the current file is markdown and a + /// rendered VM is built. Matches the SVG-rendered toggle pattern. + /// + public bool ShowMarkdownRenderedToggle => IsMarkdownFile && MarkdownDiff is not null; + + /// + /// Whether the diff pane should show the markdown rendered view in + /// lieu of the AvalonEdit source-diff editors. True when the file + /// is markdown, the rendered VM is built, the user prefers + /// rendered, AND no higher-priority surface (image diff, SVG text + /// view) is competing for the same canvas. The + /// gate excludes us symmetrically, so + /// the three surfaces (editors, markdown rendered, image diff) + /// stay mutually exclusive. + /// + public bool ShowMarkdownRendered => MarkdownDiff is not null + && RenderMarkdownRendered + && !ShowImageDiff + && !ShowSvgTextView; + // ---- Toolbar toggle state ---- [ObservableProperty] private bool _ignoreWhitespace; @@ -225,6 +270,20 @@ private readonly record struct LoadSignature( /// [ObservableProperty] private bool _renderSvgImage = true; + /// + /// Markdown-only "Rendered" toolbar toggle. When true, + /// markdown entries show the rendered + /// diff; when false, the source-text diff in the AvalonEdit + /// editors. Defaults to true — same reasoning as + /// : the rendered view is the headline + /// feature, and the user opted in by opening a markdown file. + /// Persisted across launches via the corresponding + /// AppSettings field (wired in Phase 3 of the integration). + /// Has no effect for non-markdown files; the toolbar hides the + /// toggle in that case. + /// + [ObservableProperty] private bool _renderMarkdownRendered = true; + /// /// Which sides of the side-by-side view are visible. Driven by the /// toolbar's Left / Both / Right radio-style toggle group. Has no @@ -307,6 +366,7 @@ public DiffPaneViewModel( LiveUpdates = s.LiveUpdates; SideVisibility = s.SideVisibility; RenderSvgImage = s.RenderSvgImage; + RenderMarkdownRendered = s.PreferMarkdownRendered; FontSize = s.FontSize; FontFamily = s.FontFamily; TabWidth = s.TabWidth; @@ -364,6 +424,8 @@ private void OnSettingsChanged(object? sender, SettingsChangedEventArgs e) SideVisibility = e.Current.SideVisibility; if (e.Previous.RenderSvgImage != e.Current.RenderSvgImage) RenderSvgImage = e.Current.RenderSvgImage; + if (e.Previous.PreferMarkdownRendered != e.Current.PreferMarkdownRendered) + RenderMarkdownRendered = e.Current.PreferMarkdownRendered; } finally { _suppressSettingsWrite = false; } } @@ -482,6 +544,8 @@ public Task LoadAsync(FileEntryViewModel? entry) ImageDiff = null; IsSvgFile = false; IsSvgRenderable = false; + IsMarkdownFile = false; + MarkdownDiff = null; ApplyResult(string.Empty, string.Empty, "Select a file to see its diff.", Array.Empty(), DiffHighlightMap.Empty, InlineDiffBuilder.Empty, false); LastLoadTask = Task.CompletedTask; @@ -504,6 +568,10 @@ public Task LoadAsync(FileEntryViewModel? entry) && ImageFormatDetector.DetectByExtension(change.Path) == ImageFormat.Svg; if (isSvg) { + // SVG and markdown are mutually exclusive surfaces; the SVG + // path takes the file and we clear any stale markdown state. + IsMarkdownFile = false; + MarkdownDiff = null; return BeginSvgDispatch(entry, change, _imageDecoder!, ct); } IsSvgFile = false; @@ -519,6 +587,8 @@ public Task LoadAsync(FileEntryViewModel? entry) && _imageDecoder is not null && ImageFormatDetector.DetectByExtension(change.Path) != ImageFormat.NotAnImage) { + IsMarkdownFile = false; + MarkdownDiff = null; return BeginImageDispatch(entry, change, _imageDecoder, ct); } @@ -531,6 +601,8 @@ public Task LoadAsync(FileEntryViewModel? entry) // HighlightMapChanged). _lastLoadSignature = LoadSignature.TryBuild(entry, _repository); ImageDiff = null; + IsMarkdownFile = false; + MarkdownDiff = null; ApplyResult(string.Empty, string.Empty, earlyPlaceholder, Array.Empty(), DiffHighlightMap.Empty, InlineDiffBuilder.Empty, false); LastLoadTask = Task.CompletedTask; @@ -539,7 +611,13 @@ public Task LoadAsync(FileEntryViewModel? entry) // Non-placeholder, non-image: text diff. Clear any stale image // state from a previous selection before the async load starts. + // Markdown is still text — it rides this dispatch and the source + // editors stay populated; the rendered VM is layered on top + // after the blob read so the "Rendered" toggle has both surfaces + // available. ImageDiff = null; + IsMarkdownFile = IsMarkdownPath(change.Path); + MarkdownDiff = null; IsLoading = true; var options = BuildDiffOptions(); @@ -584,6 +662,29 @@ public Task LoadAsync(FileEntryViewModel? entry) // missed change. _lastLoadSignature = LoadSignature.TryBuild(entry, _repository); ApplyResult(left, right, null, hunks, map, inline, ws); + + // Build the markdown rendered VM AFTER the source-text + // path has been populated. Runs on this thread — + // FromCurrentSynchronizationContext() puts us on the UI + // dispatcher, which is required for FlowDocument + // construction. ApplyResult above already cleared + // PlaceholderMessage on success, so the rendered view + // can take the surface uncontested. Failure to render + // (Markdig throw, etc.) falls back to the source view + // by leaving MarkdownDiff null and IsMarkdownFile true + // — the toggle hides itself because ShowMarkdownRenderedToggle + // requires MarkdownDiff is not null. + if (IsMarkdownFile) + { + try + { + MarkdownDiff = new MarkdownDiffViewModel(left, right); + } + catch + { + MarkdownDiff = null; + } + } } IsLoading = false; @@ -899,6 +1000,20 @@ private static string FormatBytes(long bytes) private static string ShortSha(string? sha) => string.IsNullOrEmpty(sha) ? "(none)" : sha.Length >= 7 ? sha[..7] : sha; + /// + /// Extension-based markdown detection. Mirrors the SVG path's + /// pattern: + /// pure-extension classification with no content sniff. .md + /// and .markdown are the two common spellings; case-insensitive. + /// + private static bool IsMarkdownPath(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var ext = System.IO.Path.GetExtension(path); + return string.Equals(ext, ".md", StringComparison.OrdinalIgnoreCase) + || string.Equals(ext, ".markdown", StringComparison.OrdinalIgnoreCase); + } + private string SafeReadSide(FileChange change, ChangeSide side) { try @@ -1006,53 +1121,64 @@ partial void OnPlaceholderMessageChanged(string? value) OnPropertyChanged(nameof(ShowInline)); } - partial void OnImageDiffChanged(ImageDiffViewModel? value) + /// + /// Fires for every display-surface + /// visibility property in one call. Use this from any partial + /// On…Changed hook for a property that participates in the + /// mutually-exclusive surface cascade (image diff, SVG text view, + /// markdown rendered, placeholder, editors / side-by-side / inline). + /// Beats the previous per-handler explicit-fan-out pattern because + /// the cascade members are listed once instead of duplicated in + /// every handler. + /// + private void RaiseDisplaySurfaceChanged() { OnPropertyChanged(nameof(ShowImageDiff)); OnPropertyChanged(nameof(ShowImageDiffModeControls)); + OnPropertyChanged(nameof(ShowSvgRenderedToggle)); + OnPropertyChanged(nameof(ShowSvgTextView)); + OnPropertyChanged(nameof(ShowMarkdownRenderedToggle)); + OnPropertyChanged(nameof(ShowMarkdownRendered)); OnPropertyChanged(nameof(ShowPlaceholder)); OnPropertyChanged(nameof(ShowEditors)); OnPropertyChanged(nameof(ShowSideBySide)); OnPropertyChanged(nameof(ShowInline)); } + partial void OnImageDiffChanged(ImageDiffViewModel? value) + { + RaiseDisplaySurfaceChanged(); + } + partial void OnIsSvgFileChanged(bool value) { - OnPropertyChanged(nameof(ShowSvgRenderedToggle)); - OnPropertyChanged(nameof(ShowSvgTextView)); - // ShowSvgTextView feeds ShowImageDiff / ShowEditors / etc. - OnPropertyChanged(nameof(ShowImageDiff)); - OnPropertyChanged(nameof(ShowImageDiffModeControls)); - OnPropertyChanged(nameof(ShowPlaceholder)); - OnPropertyChanged(nameof(ShowEditors)); - OnPropertyChanged(nameof(ShowSideBySide)); - OnPropertyChanged(nameof(ShowInline)); + RaiseDisplaySurfaceChanged(); } partial void OnIsSvgRenderableChanged(bool value) { - OnPropertyChanged(nameof(ShowSvgRenderedToggle)); - OnPropertyChanged(nameof(ShowSvgTextView)); - OnPropertyChanged(nameof(ShowImageDiff)); - OnPropertyChanged(nameof(ShowImageDiffModeControls)); - OnPropertyChanged(nameof(ShowPlaceholder)); - OnPropertyChanged(nameof(ShowEditors)); - OnPropertyChanged(nameof(ShowSideBySide)); - OnPropertyChanged(nameof(ShowInline)); + RaiseDisplaySurfaceChanged(); } partial void OnRenderSvgImageChanged(bool value) { - // ShowSvgTextView depends on RenderSvgImage and cascades into - // every other visibility gate. Notify the lot so the bindings - // flip atomically without an intermediate visual state. - OnPropertyChanged(nameof(ShowSvgTextView)); - OnPropertyChanged(nameof(ShowImageDiff)); - OnPropertyChanged(nameof(ShowImageDiffModeControls)); - OnPropertyChanged(nameof(ShowPlaceholder)); - OnPropertyChanged(nameof(ShowEditors)); - OnPropertyChanged(nameof(ShowSideBySide)); - OnPropertyChanged(nameof(ShowInline)); + RaiseDisplaySurfaceChanged(); + PersistToolbarToSettings(); + } + + partial void OnIsMarkdownFileChanged(bool value) + { + RaiseDisplaySurfaceChanged(); + } + + partial void OnMarkdownDiffChanged(MarkdownDiffViewModel? value) + { + RaiseDisplaySurfaceChanged(); + } + + partial void OnRenderMarkdownRenderedChanged(bool value) + { + RaiseDisplaySurfaceChanged(); PersistToolbarToSettings(); } @@ -1114,6 +1240,7 @@ private void PersistToolbarToSettings() ShowLineNumbers = ShowLineNumbers, WordWrap = WordWrap, RenderSvgImage = RenderSvgImage, + PreferMarkdownRendered = RenderMarkdownRendered, }); } @@ -1185,6 +1312,12 @@ public void SetCaretPosition(ChangeSide side, int oneBasedLine) /// public bool TryNavigateNextHunkInFile() { + // In markdown rendered mode the source-diff editors and hunk + // overview bar are hidden, so any caret move would scroll a + // surface the user can't see. Returning false here lets the + // cross-file orchestrator advance to the next file, matching + // the "no more hunks in this file" branch's behavior. + if (ShowMarkdownRendered) return false; if (_currentHunks.Count == 0) return false; int target = FindNextHunkFromCaret(forward: true); if (target < 0) return false; @@ -1196,6 +1329,7 @@ public bool TryNavigateNextHunkInFile() /// Caret-relative mirror of . public bool TryNavigatePreviousHunkInFile() { + if (ShowMarkdownRendered) return false; if (_currentHunks.Count == 0) return false; int target = FindNextHunkFromCaret(forward: false); if (target < 0) return false; @@ -1313,6 +1447,7 @@ private static bool CaretLineIsInsideHunk(DiffHunk h, ChangeSide side, int caret /// public void JumpToFirstHunk() { + if (ShowMarkdownRendered) return; if (_currentHunks.Count == 0) return; CurrentHunkIndex = 0; RaiseHunkNav(); @@ -1321,6 +1456,7 @@ public void JumpToFirstHunk() /// Move the caret to the last hunk in the current file. public void JumpToLastHunk() { + if (ShowMarkdownRendered) return; if (_currentHunks.Count == 0) return; CurrentHunkIndex = _currentHunks.Count - 1; RaiseHunkNav(); diff --git a/DiffViewer/ViewModels/MarkdownDiffViewModel.cs b/DiffViewer/ViewModels/MarkdownDiffViewModel.cs new file mode 100644 index 0000000..a9a54a6 --- /dev/null +++ b/DiffViewer/ViewModels/MarkdownDiffViewModel.cs @@ -0,0 +1,42 @@ +using System.Windows.Documents; +using CommunityToolkit.Mvvm.ComponentModel; +using DiffViewer.Rendering; + +namespace DiffViewer.ViewModels; + +/// +/// Markdown rendered-diff sibling view-model. Set on +/// when the currently loaded +/// entry is a .md / .markdown file, after the blob text has +/// been read. Holds the rendered shown by +/// MarkdownDiffView. +/// +/// Threading: the constructor calls +/// , which produces a +/// dispatcher-affine . Construct this VM on +/// the UI thread (the existing text-dispatch path's ContinueWith +/// is on , +/// which is the correct seam). The blob read itself can stay on a +/// background thread; only the assembly +/// needs the dispatcher. +/// +/// The VM is immutable from the caller's perspective: it's built +/// once with the old / new text and never mutated. A subsequent reload +/// produces a brand-new VM instance, mirroring how +/// is replaced wholesale rather than +/// updated in place. +/// +public sealed partial class MarkdownDiffViewModel : ObservableObject +{ + /// + /// Rendered diff document. Bound directly into + /// + /// by the view. + /// + public FlowDocument Document { get; } + + public MarkdownDiffViewModel(string leftText, string rightText) + { + Document = MarkdownDiffRenderer.Render(leftText ?? string.Empty, rightText ?? string.Empty); + } +} diff --git a/DiffViewer/Views/DiffPaneView.xaml b/DiffViewer/Views/DiffPaneView.xaml index 870ddd1..7f0f94f 100644 --- a/DiffViewer/Views/DiffPaneView.xaml +++ b/DiffViewer/Views/DiffPaneView.xaml @@ -206,6 +206,14 @@ + + + + + + + + + diff --git a/DiffViewer/Views/MarkdownDiffView.xaml b/DiffViewer/Views/MarkdownDiffView.xaml new file mode 100644 index 0000000..33103f7 --- /dev/null +++ b/DiffViewer/Views/MarkdownDiffView.xaml @@ -0,0 +1,20 @@ + + + + + diff --git a/DiffViewer/Views/MarkdownDiffView.xaml.cs b/DiffViewer/Views/MarkdownDiffView.xaml.cs new file mode 100644 index 0000000..1b09998 --- /dev/null +++ b/DiffViewer/Views/MarkdownDiffView.xaml.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Navigation; + +namespace DiffViewer.Views; + +/// +/// Code-behind for . The view is mostly +/// XAML wiring (a bound to +/// ), +/// plus the one piece of imperative work WPF demands: routing +/// to the default browser. +/// +/// Why the handler is needed. +/// with set +/// only auto-navigates when the document is hosted in a +/// or +/// . A plain +/// raises the +/// routed event but doesn't +/// act on it, so clicks would look interactive (cursor changes, link is +/// underlined) and do nothing. The +/// emits real +/// elements (including the orange URL-changed variant whose tooltip +/// shows both old and new URLs), so navigation has to work for the +/// rendered view to be useful. +/// +public partial class MarkdownDiffView : UserControl +{ + public MarkdownDiffView() + { + InitializeComponent(); + + // Bubbled routed event — attaching at the UserControl level + // catches every Hyperlink in the hosted FlowDocument without + // needing per-link wiring. + AddHandler(Hyperlink.RequestNavigateEvent, + new RequestNavigateEventHandler(OnHyperlinkRequestNavigate)); + } + + private static void OnHyperlinkRequestNavigate(object sender, RequestNavigateEventArgs e) + { + // Mirrors BrowserNotifyUpdateService.OpenUrlInDefaultBrowser: + // UseShellExecute = true lets the OS resolve http(s) URIs to the + // user's default browser. Best-effort: a malformed URI or shell + // failure swallows quietly rather than crashing the diff pane. + try + { + Process.Start(new ProcessStartInfo + { + FileName = e.Uri.AbsoluteUri, + UseShellExecute = true, + }); + } + catch + { + // intentional: nothing useful to do here + } + e.Handled = true; + } +}