Skip to content

Markdown rendered-diff view#18

Merged
geevensingh merged 5 commits into
masterfrom
feature/markdown-rendered-diff
Jun 7, 2026
Merged

Markdown rendered-diff view#18
geevensingh merged 5 commits into
masterfrom
feature/markdown-rendered-diff

Conversation

@geevensingh

Copy link
Copy Markdown
Owner

Adds a markdown rendered-diff view: when viewing .md / .markdown files, a "Rendered" toolbar toggle switches between the source-text diff (the existing Side-by-side / Inline editors) and a rendered FlowDocument view that shows the diff inside the rendered markdown.

This feature was designed via a feasibility spike on branch spike/markdown-diff-preview (4 commits, latest e4373e9, not merged). The spike's full write-up lives at spikes/MarkdownDiffSpike/FINDINGS.md on that branch. This PR is the production integration of the spike's verdict.

What you see

  • Rendered toggle appears in the toolbar only on .md / .markdown files. Defaults to rendered view (mirrors the SVG Rendered toggle pattern). User preference persists across launches via the new preferMarkdownRendered setting.
  • Removed text appears with red background + strikethrough; added text with green background.
  • Word-level diff inside edited paragraphs/headings/list items, with inline formatting preserved on unchanged tokens (bold / italic / inline-code / hyperlink styling carries through the diff).
  • URL-only link changes (link text identical, URL differs) render in orange with a tooltip showing both URLs — disclosed honestly instead of silently invisible.
  • Nested list items diff at item granularity (a single inner edit doesn't mark the whole outer item as rewritten).
  • 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 (deliberately out of scope this round).
  • F7 / F8 hunk navigation in rendered mode acts as "advance to next file" since the source editors and hunk overview bar are hidden in that mode.

Out of scope (deliberate)

  • Side-by-side rendered mode (unified-render only)
  • Move detection (LCS limitation, same as existing source diff)
  • Context-aware heading-ancestry keys for block matching
  • WPF Table rendering of markdown tables
  • Configurable similarity threshold (hardcoded 0.30)
  • A new release tag — that decision is the user's per AGENTS.md §12

Phasing

Each phase is one commit. Reviewers can read them in order:

  1. a5612de Phase 1 — Lift MarkdownDiffRenderer from the spike into DiffViewer/Rendering/; add Markdig dep; 12 black-box renderer tests using [StaFact]. Engine is dead code at this point.
  2. 8af44d1 Phase 2 — MarkdownDiffViewModel + MarkdownDiffView + DiffPaneViewModel dispatch + visibility cascade refactor (RaiseDisplaySurfaceChanged() helper consolidates the per-handler OnPropertyChanged fan-out) + hunk-nav gating. 16 new tests.
  3. ab49b86 Phase 3 — Toolbar Rendered toggle button + AppSettings.PreferMarkdownRendered + v8→v9 migration + serializer round-trip + CHANGELOG [Unreleased] entry. 4 new tests.

Definition of done

  • dotnet build -c Release: clean, zero new warnings
  • dotnet test: 1479 passed (1447 baseline + 32 new), 0 failed
  • dotnet publish DiffViewer\DiffViewer.csproj -c Release -o publish: single-file exe (144 MB) launches and responds
  • .editorconfig honored; no new diagnostic suppressions
  • CHANGELOG.md [Unreleased] updated per AGENTS.md §12
  • Four load-bearing libraries (CommunityToolkit.Mvvm, AvalonEdit, DiffPlex, LibGit2Sharp) unchanged. Markdig is the one new dependency (pure managed, no native libs, no impact on the single-file publish — verified)

Design decisions captured during planning

I asked five clarifying questions before writing code; answers shaped the plan:

Question Decision
Unified or also side-by-side rendered? Unified only
Default mode on .md files? Rendered
Tables? Plain-text fallback
URL-only link changes? Orange tint + tooltip (matches "show differences honestly" UX principle)
Direct-to-master or PR? PR (this one)

The plan was then rubber-duck-reviewed before implementation. Three blocking findings were adopted: threading is made explicit (Render is UI-thread-only, dispatched in the ContinueWith of the existing text load); tests use [StaFact] and are black-box on the produced FlowDocument; the visibility cascade gets a small RaiseDisplaySurfaceChanged() helper instead of duplicating the same OnPropertyChanged list in every partial handler. Settings migration is handled in all four touchpoints (AppSettings, SettingsJsonSerializer.Serialize/Deserialize, SettingsMigrations + round-trip tests).


Session

  • AI-Local-Session: 461e0a61-a892-44b3-897d-e4d2d37f1142
  • AI-Cloud-Session: 7ea21bb1-fbcf-4d33-a5f9-438a3a76b475

Geeven Singh and others added 3 commits June 6, 2026 14:05
First of four planned commits to integrate the markdown rendered-diff
feature explored in the spike on spike/markdown-diff-preview. This
commit is the ENGINE LIFT ONLY - the renderer is dead code in the
sense that no caller wires it in yet. Phase 2 will add the
ViewModel/View/dispatch, Phase 3 the toolbar toggle + settings +
CHANGELOG, Phase 4 the PR.

Changes:

- Add Markdig 0.37.0 PackageReference to DiffViewer.csproj. Pure
  managed; no native libs; no impact on the single-file publish.
- Copy spikes/MarkdownDiffSpike/MarkdownDiffRenderer.cs verbatim to
  DiffViewer/Rendering/MarkdownDiffRenderer.cs, with three small
  changes per the spike's FINDINGS:
    1. namespace changed from MarkdownDiffSpike to
       DiffViewer.Rendering
    2. Added .Freeze() to UrlChangedFg (the spike missed it; not a
       runtime issue today but a production-hardening fix flagged in
       the rubber-duck pass).
    3. Class-level XML doc expanded to state the UI-thread-only
       contract explicitly (FlowDocument is dispatcher-affine; we
       inherit the contract).
- Add 12 black-box renderer tests in
  DiffViewer.Tests/Rendering/MarkdownDiffRendererTests.cs covering the
  spike's sample matrix:
    - identical inputs produce no decorations
    - happy path produces blocks + insert signal
    - inline bold edit preserves bold on unchanged tokens
    - inline code edit preserves monospace on unchanged tokens
    - link text change renders as Hyperlink
    - link URL-only change tints orange with both-URLs tooltip
    - nested list inner-item edit diffs at item granularity
    - table cell change falls back to plain-text (no WPF Table)
    - heavy rewrite with no shared words falls back to two
      full-block tints (similarity gate kicks in)
    - small list-item replace stays coalesced via short-block
      exemption
    - reordered sections render as separate delete + insert
      (caveat 3a, pinned as intentional)
  All tests use [StaFact] from Xunit.StaFact because FlowDocument and
  its descendants are dispatcher-affine.

Definition of done verified:
- dotnet build -c Release: clean, 0 new warnings
- dotnet test: 1459 passed (1447 baseline + 12 new), 0 failed
- dotnet publish DiffViewer\DiffViewer.csproj -c Release -o publish:
  single-file exe (144 MB, similar to prior baseline) launches and
  loads successfully
- .editorconfig honored, no new suppressions
- four load-bearing libraries (CommunityToolkit.Mvvm, AvalonEdit,
  DiffPlex, LibGit2Sharp) versions unchanged

AI-Local-Session: 461e0a61-a892-44b3-897d-e4d2d37f1142
AI-Cloud-Session: 7ea21bb1-fbcf-4d33-a5f9-438a3a76b475
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Second of four planned commits. Phase 1 lifted the renderer; this
commit makes it visible: extension-based dispatch in DiffPaneViewModel
populates a sibling MarkdownDiffViewModel on .md/.markdown files, a
new MarkdownDiffView FlowDocumentScrollViewer host shows the rendered
diff in lieu of the source editors, and the visibility cascade is
refactored to a single RaiseDisplaySurfaceChanged() helper per the
rubber-duck recommendation.

DiffPaneViewModel changes:

- New ObservableProperties: IsMarkdownFile (extension flag),
  MarkdownDiff (sibling VM), RenderMarkdownRendered (user toggle,
  defaults true).
- New derived properties: ShowMarkdownRenderedToggle (file is
  markdown AND rendered VM is built) and ShowMarkdownRendered
  (toggle gate AND user prefers rendered AND not behind a
  higher-priority surface).
- ShowEditors now also gated on !ShowMarkdownRendered so the source
  editors and the rendered view stay mutually exclusive on the
  same canvas.
- New RaiseDisplaySurfaceChanged() helper that fires
  OnPropertyChanged for every display-surface visibility property
  in one call. ALL existing partial On-Changed methods for
  display-affecting properties (OnImageDiffChanged,
  OnIsSvgFileChanged, OnIsSvgRenderableChanged,
  OnRenderSvgImageChanged) refactored to call it. New markdown
  partial methods do the same.
- LoadAsync detects .md/.markdown extension after the SVG and
  image-dispatch gates fail. Sets IsMarkdownFile in the synchronous
  pre-Task.Run block; clears MarkdownDiff to drop any stale prior
  load. After the ContinueWith re-marshals to the UI thread (where
  FlowDocument construction must happen because it's
  dispatcher-affine), builds new MarkdownDiffViewModel(left, right).
  try/catch around the renderer so a Markdig throw falls back
  gracefully to source-only mode rather than failing the whole
  load.
- Markdown state cleared in every early-return path that already
  clears ImageDiff: null entry, SVG dispatch entry, image dispatch
  entry, placeholder.
- IsMarkdownPath helper: case-insensitive .md / .markdown extension
  check.
- TryNavigateNextHunkInFile / TryNavigatePreviousHunkInFile /
  JumpToFirstHunk / JumpToLastHunk now early-return when
  ShowMarkdownRendered. The AvalonEdit editors and hunk-overview
  bar are hidden in rendered mode, so any caret move would scroll
  a surface the user can't see. Returning false from
  TryNavigate* makes the cross-file orchestrator advance to the
  next file (matching the "no more hunks in this file" behavior).

New types:

- ViewModels/MarkdownDiffViewModel.cs: sealed partial : ObservableObject
  wrapping a FlowDocument. Constructor takes (leftText, rightText),
  immediately calls MarkdownDiffRenderer.Render. Construct on the
  UI thread.
- Views/MarkdownDiffView.xaml + .cs: thin FlowDocumentScrollViewer
  bound to Document. Pure XAML wiring, no code-behind logic.
- DiffPaneView.xaml: MarkdownDiffView sibling to ImageDiffView with
  visibility binding to ShowMarkdownRendered.

Tests added:

- DiffViewer.Tests/ViewModels/MarkdownDiffViewModelTests.cs: 4 tests
  covering construction with non-trivial / identical / empty / null
  inputs.
- DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs: 12 new tests
  covering: extension detection (.md, .markdown, case-insensitive),
  non-markdown leaves state clear, rendered toggle flips view
  without re-reading blobs, navigating to non-markdown after
  markdown clears the state, null entry clears state, hunk nav
  early-returns in rendered mode, binary file clears state, toggle
  state independence from file-kind flag, the visibility cascade
  fires the right OnPropertyChangeds, and SVG dispatch doesn't
  leak markdown flags.

Definition of done verified:
- dotnet build -c Release: clean, 0 new warnings
- dotnet test: 1475 passed (1459 from Phase 1 + 16 new), 0 failed
- dotnet publish -c Release: single-file exe 144 MB, launches and
  responds
- .editorconfig honored, no new suppressions
- Markdig dep version unchanged

AI-Local-Session: 461e0a61-a892-44b3-897d-e4d2d37f1142
AI-Cloud-Session: 7ea21bb1-fbcf-4d33-a5f9-438a3a76b475
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…+ CHANGELOG

Third of four planned commits. Phase 1 lifted the renderer; Phase 2
wired the VM/View/dispatch with the rendered surface visible. Phase 3
makes the user's preference persistent and adds the user-facing
toolbar control + release notes.

Settings:

- AppSettings.cs: bump CurrentSchemaVersion 8 -> 9. New
  PreferMarkdownRendered bool with default true. XML doc explains
  the parallel to RenderSvgImage (added in v6).
- SettingsMigrations.cs: register MigrateV8ToV9 as a no-op. The new
  field has a safe default so pre-v9 files load with the rendered
  view enabled by default — matching how v5->v6 added RenderSvgImage.
- SettingsJsonSerializer.cs: add preferMarkdownRendered to both
  Serialize and Deserialize. Serializer uses defaults.PreferMarkdownRendered
  for the missing-field fallback so a v9 file with the field stripped
  still round-trips.
- SettingsServiceTests.cs: 2 new tests covering the v8->v9 migration
  (field defaults to true; other fields preserved) and the
  round-trip (save false, reload, observe false).

DiffPaneViewModel wiring:

- Seed RenderMarkdownRendered from settings.PreferMarkdownRendered in
  the constructor's settings-seed block (mirrors RenderSvgImage).
- Push external settings changes back into the VM in OnSettingsChanged
  (mirrors RenderSvgImage's diff-and-push pattern).
- Add PreferMarkdownRendered = RenderMarkdownRendered to
  PersistToolbarToSettings so VM toggles round-trip to disk.
- 2 new tests: RenderMarkdownRendered_TogglePersistsToSettings and
  RenderMarkdownRendered_ExternalSettingsChange_PushesIntoViewModel
  (mirror the existing SVG persistence tests).

Toolbar:

- DiffToolbarView.xaml: new "Rendered" ToggleButton in the leftmost
  StackPanel, visible only when ShowMarkdownRenderedToggle (a .md/
  .markdown file with a built rendered VM). IsChecked two-way to
  RenderMarkdownRendered. Sibling of the SVG Rendered toggle; same
  StackPanel pattern with the trailing thin Separator.

CHANGELOG:

- New "Markdown rendered-diff view" bullet at the top of
  [Unreleased] / ### Added. Describes the feature, the toggle, the
  default-on behavior, the inline-formatting preservation, the
  URL-change orange tint, the per-item nested list diff, the
  similarity-gated coalesce, the table fallback, the persistence,
  and the F7/F8 hunk-nav skip behavior.

Definition of done verified:
- dotnet build -c Release: clean, 0 new warnings
- dotnet test: 1479 passed (1475 from Phase 2 + 4 new persistence/
  migration), 0 failed
- dotnet publish -c Release: single-file exe 144 MB, launches and
  responds
- .editorconfig honored, no new suppressions
- four load-bearing libraries' versions unchanged (Markdig version
  also unchanged from Phase 1)
- CHANGELOG.md [Unreleased] entry present per AGENTS.md sec 12

Phase 4 (next): open PR for review.

AI-Local-Session: 461e0a61-a892-44b3-897d-e4d2d37f1142
AI-Cloud-Session: 7ea21bb1-fbcf-4d33-a5f9-438a3a76b475
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolves one conflict in CHANGELOG.md: master shipped v1.7.0
(promoting [Unreleased] to [1.7.0] - 2026-06-05 with the diffviewer
PATH-registrar entry), so the auto-merge placed my markdown rendered-
diff bullet under [1.7.0]. The markdown feature has not shipped, so
moved that bullet into a fresh [Unreleased] section at the top per
Keep a Changelog conventions. The v1.7.0 entries (PATH registrar,
running-version in Settings, dev-build versioning, fresh-install
empty-state shell) stay under [1.7.0] - 2026-06-05 unchanged.

No code conflicts. The PATH-registrar feature added new files only
(no schema changes to AppSettings / SettingsMigrations / SettingsJsonSerializer)
so the markdown integration's v8->v9 schema bump applies cleanly on
top.

Definition of done verified post-merge:
- dotnet restore -r win-x64: clean
- dotnet build -c Release: clean, 0 new warnings
- dotnet test: 1520 passed (1479 from feature branch + 41 new from
  master's PATH-registrar work), 0 failed

AI-Local-Session: 461e0a61-a892-44b3-897d-e4d2d37f1142
AI-Cloud-Session: 7ea21bb1-fbcf-4d33-a5f9-438a3a76b475
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new markdown rendered-diff “display surface” to DiffViewer. For .md / .markdown files, the diff pane can switch between the existing source-text diff (AvalonEdit) and a rendered FlowDocument view that embeds diff decorations into the rendered markdown, with a persisted user preference.

Changes:

  • Introduces MarkdownDiffRenderer (Markdig-based) to produce a diff-decorated FlowDocument, plus a sibling MarkdownDiffViewModel and MarkdownDiffView.
  • Integrates the rendered surface into DiffPaneViewModel (visibility cascade, load dispatch, hunk-nav gating) and adds a toolbar toggle.
  • Persists the preference via settings schema v9 (preferMarkdownRendered) with serialization and migration coverage, plus new renderer and VM tests.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
DiffViewer/Views/MarkdownDiffView.xaml.cs Adds new rendered markdown diff view code-behind (currently minimal).
DiffViewer/Views/MarkdownDiffView.xaml Hosts a FlowDocumentScrollViewer bound to the rendered document.
DiffViewer/Views/DiffToolbarView.xaml Adds the markdown “Rendered” toggle, visible only when applicable.
DiffViewer/Views/DiffPaneView.xaml Adds the MarkdownDiffView surface to the diff pane.
DiffViewer/ViewModels/MarkdownDiffViewModel.cs New VM that builds/holds the rendered FlowDocument.
DiffViewer/ViewModels/DiffPaneViewModel.cs Integrates markdown rendered mode into load flow, visibility cascade, settings, and hunk navigation gating.
DiffViewer/Services/SettingsMigrations.cs Adds v8→v9 migration step (no-op, defaultable).
DiffViewer/Services/SettingsJsonSerializer.cs Serializes/deserializes preferMarkdownRendered.
DiffViewer/Rendering/MarkdownDiffRenderer.cs New Markdig-based renderer that builds a diff-decorated FlowDocument.
DiffViewer/Models/AppSettings.cs Bumps schema to v9 and adds PreferMarkdownRendered.
DiffViewer/DiffViewer.csproj Adds Markdig dependency.
DiffViewer.Tests/ViewModels/MarkdownDiffViewModelTests.cs New STA tests for VM construction and degenerate inputs.
DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs New tests for markdown dispatch, visibility gates, navigation gating, and settings propagation.
DiffViewer.Tests/Services/SettingsServiceTests.cs Adds migration + round-trip tests for the new settings field.
DiffViewer.Tests/Rendering/MarkdownDiffRendererTests.cs Adds black-box STA tests covering renderer behavior and caveats.
CHANGELOG.md Documents the new markdown rendered-diff feature under [Unreleased].

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DiffViewer/Views/MarkdownDiffView.xaml.cs
copilot-pull-request-reviewer flagged that MarkdownDiffRenderer emits
Hyperlink elements with NavigateUri set, but WPF's
FlowDocumentScrollViewer (unlike NavigationWindow / Frame) doesn't
auto-handle the RequestNavigate routed event. Without a handler, link
clicks raise the event but do nothing - links look interactive
(cursor changes, underlined) and silently fail. Verified by checking
the WPF docs and the existing repo pattern in
BrowserNotifyUpdateService.OpenUrlInDefaultBrowser.

Fix: add a UserControl-level RequestNavigate handler in
MarkdownDiffView.xaml.cs ctor. Routes the click through
Process.Start(new ProcessStartInfo { FileName = uri, UseShellExecute = true })
- the OS default-browser shim - matching the existing
BrowserNotifyUpdateService and SettingsViewModel patterns.
Wrapped in try/catch (best-effort: a malformed URI or shell failure
swallows quietly rather than crashing the diff pane). e.Handled = true
stops further bubbling.

Particularly important for sample 04 (link text change) and sample 05
(URL-only change, where the orange tint + both-URLs tooltip is much
less useful if the user can't click through to the new URL).

No test per AGENTS.md sec 10 carve-out: view code-behind doing event-
handler delegation is exempt. The handler is a thin shim around
Process.Start that mirrors existing tested-by-usage patterns.

Definition of done verified:
- dotnet build -c Release: clean, 0 new warnings
- dotnet test: 1520 passed, 0 failed (unchanged from pre-fix)

AI-Local-Session: 461e0a61-a892-44b3-897d-e4d2d37f1142
AI-Cloud-Session: 7ea21bb1-fbcf-4d33-a5f9-438a3a76b475
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@geevensingh geevensingh merged commit 8e8c083 into master Jun 7, 2026
1 check passed
@geevensingh geevensingh deleted the feature/markdown-rendered-diff branch June 7, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants