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 = "