Skip to content

feat: CodeMirror 6 side-by-side diff editor#507

Merged
PureWeen merged 18 commits intomainfrom
feat/codemirror-diff-editor
Apr 15, 2026
Merged

feat: CodeMirror 6 side-by-side diff editor#507
PureWeen merged 18 commits intomainfrom
feat/codemirror-diff-editor

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

@PureWeen PureWeen commented Apr 5, 2026

CodeMirror 6 Diff Editor

Adds a side-by-side diff editor using CodeMirror 6, integrated into the existing DiffView component.

Features

  • Side-by-side MergeView with syntax highlighting for C#, JS/TS, Python, CSS, HTML, JSON, XML/XAML, Markdown, Bash
  • Table/Editor toggle per diff file — switch between existing table view and CodeMirror
  • Collapsed unchanged regions (like GitHub PR view)
  • Line numbers on both sides with gutter change markers
  • Custom dark theme matching PolyPilot's palette
  • Search (Ctrl+F), fold gutters, bracket matching
  • Pre-built 661KB bundle — works offline, no CDN dependency

Technical Details

  • CodeMirror 6 bundle built from codemirror-src/ via esbuild (reproducible)
  • DiffParser.ReconstructOriginal/ReconstructModified extract both sides from parsed diffs
  • Instance lifecycle managed via JS Map + IAsyncDisposable
  • 3 new tests for reconstruct methods
  • Also fixes pre-existing ChangeModelAsync test compilation errors (missing named param)

Verified

  • Build: 0 errors
  • Tests: 3132/3132 pass
  • MauiDevFlow: C# syntax highlighting, diff colors, collapsed regions all confirmed via screenshots

@PureWeen PureWeen force-pushed the feat/codemirror-diff-editor branch from 651ba39 to 8bbe8bc Compare April 5, 2026 04:59
@PureWeen
Copy link
Copy Markdown
Owner Author

PureWeen commented Apr 5, 2026

🔍 Multi-Reviewer Code Review — PR #507 (Re-review #4 — Final)

CodeMirror 6 Side-by-Side Diff Editor

3 independent reviewers re-analyzed this PR after commit f6edbdd6 ("fix: disable Apply without FileContentProvider and fix editor-view renamed-file comments"). CI: ⚠️ No checks configured on this branch.


Previous Findings — Status

# Severity Finding Status
NEW-1 🟡 Inline chat diffs missing FileContentProvider — Apply writes blank gaps ✅ FIXED
NEW-2 🟡 Editor-view line comment on renamed file uses wrong filename ✅ FIXED

Details

NEW-1 ✅: CanApplyEdit now gates on both conditions: OnApplyEdit.HasDelegate && FileContentProvider is not null. Since ChatMessageItem passes OnApplyEdit but not FileContentProvider, the Apply button is correctly hidden in chat diffs. The review panel passes both, so Apply works only where content can be gap-filled. Confirmed by all 3 reviewers.

NEW-2 ✅: Three-layer fix verified by all 3 reviewers:

  • C# InitCodeMirrorForFile passes oldFileName to createMergeView
  • JS commentMeta stores oldFileName: oldFileName || filename
  • JS getLineClickExtensions selects: side === 'original' ? meta.oldFileName : meta.fileName
  • Table view already used OldFileName correctly from the previous round

Full Finding History (Reviews 1–4)

Round Finding Final Status
R1 🔴 Fire-and-forget dispose + sync Clear() leaks JS instances ✅ Fixed (R2)
R1 🔴 Stale InitCodeMirrorForFile writes orphaned ID ✅ Fixed (R2)
R1 🟡 Double-init on rapid toggle ✅ Fixed (R2)
R1 🟡 DisposeCmInstance races with DisposeAsync ✅ Fixed (R2)
R1 🟡 ReconstructOriginal/Modified omit inter-hunk gaps ✅ Fixed (R2)
R1 🟡 Pre-built bundle without integrity verification ⚠️ Partially fixed (R2) — acceptable
R1 🟢 O(n²) Files.IndexOf in render loop ✅ Fixed (R2)
R1 🟢 Bare catch {} blocks ✅ Fixed (R2)
R2 🟡 Post-loop Clear() wipes new instances ✅ Fixed (R2)
R2 🔴 Apply writes blank placeholder lines → data loss ✅ Fixed (R3)
R2 🔴 Path traversal bypass ✅ Fixed (R3)
R2 🟡 Renamed file comment wrong filename (table) ✅ Fixed (R3)
R2 🟡 _applySaveStatus bleeds across files ✅ Fixed (R3)
R2 🟢 Uncached AddedLineCount/RemovedLineCount ✅ Fixed (R3)
R2 🟢 Stale CSS comment ✅ Fixed (R3)
R3 🟡 Chat diffs missing FileContentProvider ✅ Fixed (R4)
R3 🟡 Editor renamed-file comment wrong filename ✅ Fixed (R4)

17 findings across 4 review rounds — all resolved. 🎉


ℹ️ Discarded Findings (1/3 only, not confirmed)

  • simulateLineClick doesn't use oldFileName for original-side — test utility only, not called in production
  • Double-logging on Apply write failure — throw in handler propagates to DiffView catch. Cosmetic stderr noise.
  • FileContentProvider not plumbed to inline chat diffs — intentional design gap (Apply disabled there via CanApplyEdit guard). Follow-up feature, not a bug.
  • JS options null dereference in createMergeView — false positive; JS default parameter options = {} handles null

Recommendation: ✅ Approve

All findings from all 4 review rounds are resolved. The PR adds a substantial, well-tested feature (CodeMirror 6 diff editor with side-by-side view, syntax highlighting, editable modified side, Apply, line commenting, file picker) with solid lifecycle management (snapshot-before-clear disposal, generation counter, double-init guard, path traversal protection, content-aware gap filling).

Ship it! 🚀

@PureWeen PureWeen force-pushed the feat/codemirror-diff-editor branch 5 times, most recently from 501599b to 71986f7 Compare April 7, 2026 20:32
PureWeen and others added 9 commits April 13, 2026 19:17
Integrates CodeMirror 6 as an alternative diff viewer alongside the
existing table-based DiffView. Each file in a diff now shows Table/Editor
toggle buttons in the file header.

Components:
- CodeMirror 6 bundle (660KB minified) with C#, JS, CSS, HTML, JSON,
  XML, Python, Markdown language support
- Custom dark theme matching PolyPilot's color palette
- MergeView for side-by-side diff with collapsed unchanged regions
- Line numbers, bracket matching, fold gutters, Ctrl+F search
- DiffParser.ReconstructOriginal/Modified for extracting both sides

Also fixes pre-existing ChangeModelAsync test compilation errors
(missing named parameter for reasoningEffort).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Use @codemirror/legacy-modes pre-built csharp export instead of
  custom clike config (simpler, maintained upstream)
- Add shell/bash/zsh syntax highlighting
- Move CodeMirror DOM styles to global app.css (Blazor scoped CSS
  can't reach CodeMirror's dynamically created elements)
- Hide merge-view revert buttons (read-only view)
- Add merge-spacer gutter styling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace async void SetViewMode with sync method + _pendingEditorInit
  set. CodeMirror initialization now happens in OnAfterRenderAsync
  (guaranteed DOM availability) instead of Task.Delay(50) (race-prone).
- Add 4 edge case tests: deleted file, empty hunks, multi-hunk,
  context-only diffs for ReconstructOriginal/ReconstructModified.
- Tests: 3136/3136 pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-hunk gaps

- #1 (CRITICAL): Snapshot CM instance IDs before clearing dictionaries in
  OnParametersSet, preventing InvalidOperationException from enumerator
  invalidation and ensuring all JS instances are properly disposed
- #2 (CRITICAL): Add generation counter to InitCodeMirrorForFile; stale
  completions (from a previous diff) are detected and disposed immediately
- #3: Double-init guard — skip InitCodeMirrorForFile if _cmInstances
  already contains an entry for the fileIdx
- #4: DisposeCmInstance replaced with snapshot-then-remove pattern;
  _pendingEditorInit cleared on Table toggle to prevent stale inits
- #5: ReconstructOriginal/Modified now insert blank placeholder lines
  for inter-hunk gaps to preserve correct line numbering in CM MergeView
- #6: Add SRI integrity hash (sha384) to codemirror-bundle.js script tag
- #7: Replace O(n²) Files.IndexOf(file) with indexed for loop
- #8: All catch blocks now log to Console.Error with [DiffView] prefix
- NEW-1: Post-loop Clear() race eliminated — DisposeJsInstancesByIdAsync
  works on snapshotted arrays, never touches _cmInstances dictionary
- Added _disposed guard to DisposeAsync for safe double-dispose

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add thorough test coverage for the CodeMirror diff editor feature:

- Inter-hunk gap placeholder tests: verify ReconstructOriginal/Modified
  insert correct blank lines between distant hunks (review fix #5)
- PairLines side-by-side pairing: context-only, matched pairs, uneven
  remove/add counts, pure additions/deletions, mixed interleaving,
  empty hunks, multiple independent change blocks
- Multi-file mixed types: new + deleted + modified + renamed in one diff
- Edge cases: CRLF handling, empty added/removed lines, hunk headers
  with and without function names, line number sequencing, insertions
  causing offset, multiple hunks resetting line numbers
- ShouldRenderDiffView integration: Read/view tool filtering, null tool
- IsPlainTextViewTool case insensitivity
- Reconstruction round-trip integrity: single-line, multi-hunk gaps with
  exact line counts, gap adjustment for added lines
- Real-world patterns: binary file notices, index lines, three-hunk diffs,
  hunk headers without comma

Total DiffParser tests: 67 (28 existing + 39 new), all passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ShouldRenderDiffView already validates the content is a parseable
unified diff. The extra Parse + Count > 0 guard in the render body
was always true when _hasDiffOutput was set, making the else branch
(fallback <pre>) dead code. DiffView internally parses via
OnParametersSet, so the removed call was a wasted parse per render.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a desktop Review button for linked PRs that opens a side-by-side diff panel, fetches gh pr diff output, and renders it with the DiffView file picker/editor toggle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen force-pushed the feat/codemirror-diff-editor branch from 5a8b97f to 8fb9e19 Compare April 14, 2026 00:18
PureWeen and others added 9 commits April 13, 2026 19:47
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…false)

The merge view (diff editor) had both EditorState.readOnly.of(true) and
EditorView.editable.of(false). The editable(false) sets contentEditable=false
on the DOM, which prevents ALL user interaction: no clicking, no text
selection, no gutter line number clicks. Users reported 'Editor' toggle
does nothing because the rendered CodeMirror was completely unresponsive.

Fix: remove EditorView.editable.of(false) from the merge view extensions.
EditorState.readOnly.of(true) alone prevents editing while allowing
clicking, text selection, and gutter interaction to work normally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The merge view now has asymmetric editor configs:
- Original (a/left): readonly + non-editable (view-only reference)
- Modified (b/right): fully editable with history, undo/redo,
  bracket completion, indent-with-tab, and active line highlighting

Also adds getModifiedContent(instanceId) JS API so the Blazor side
can retrieve user edits from the modified pane.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a '💾 Apply' button in the diff editor header that:
- Reads the modified content from the CodeMirror editor via JS interop
- Writes it back to the file on disk via the component chain
- Shows brief ✅ Saved / ❌ Error feedback with auto-clear

Component chain: DiffView → ChatMessageItem → ChatMessageList →
ExpandedSessionView → File.WriteAllTextAsync with path traversal guard.

New model: DiffApplyEditRequest(FileName, Content) in DiffParser.cs.
New JS API: PolyPilotCodeMirror.getModifiedContent(instanceId).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ws line endings

- Wire OnCommentRequested and OnApplyEdit callbacks to DiffView in
  ChatMessageType.Diff case, enabling line-number click → comment box
  → send to chat workflow for standalone diff messages
- Use explicit '\n' in ReconstructOriginal/ReconstructModified instead
  of AppendLine() which emits \r\n on Windows, fixing 6 test failures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Move comment box from fixed position to inline (right next to clicked line in table view, below editor in editor view)
- Add side label (original/modified) to comment box header
- Add scroll-into-view + auto-focus for comment textarea
- Add .diff-comment-row and .diff-comment-side CSS styles
- Allowlist DiffView.razor.css 0.85em in FontSizingEnforcementTests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ance

Adds a simulateLineClick() function that directly invokes the .NET
HandleEditorLineClick interop, bypassing DOM event limitations in
WebView for testing. Also stores commentMeta on the instance map
entry so test helpers can access dotNetRef and file metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔴 NEW-1: Fix Apply writing blank placeholder lines to disk (data loss)
- ReconstructOriginal/Modified now accept optional fileLines parameter
- When provided, inter-hunk gaps are filled with real file content
- DiffView gets FileContentProvider parameter to load files at init
- ExpandedSessionView wires LoadFileContentAsync from working directory
- Trailing lines after last hunk are also appended

🔴 NEW-2: Fix path traversal bypass in Apply handler
- Add trailing directory separator before StartsWith check
- Use OrdinalIgnoreCase for Windows/macOS case-insensitive filesystems
- Same fix applied to LoadFileContentAsync

🟡 NEW-3: Renamed file comment uses correct filename
- Original-side line comments now use OldFileName for renamed files

🟡 NEW-4: _applySaveStatus cleared on file switch
- SelectFile() now resets _applySaveStatus to prevent bleed

🟢 NEW-5: Cache AddedLineCount/RemovedLineCount
- DiffFile now caches these O(n) computed properties via nullable backing fields

🟢 NEW-6: Fix stale CSS comment on .cm-merge-revert
- Updated comment to reflect that modified side is editable

Tests: 8 new tests (3411 total, 0 failures)
- ReconstructModified/Original with fileLines gap-fill
- Trailing lines append
- Backward compat (blank gaps without fileLines)
- Cached line count verification
- Path traversal: sibling dir, valid subpath, dot-dot escape

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…named file comments

🟡 NEW-1: CanApplyEdit now requires FileContentProvider to be set.
Inline chat diffs (ChatMessageItem) don't provide a FileContentProvider,
so the Apply button is hidden — preventing blank-gap data loss on that path.

🟡 NEW-2: Editor-view line comments on renamed files now use the correct
filename per side. createMergeView accepts oldFileName parameter; the
original-side click handler uses oldFileName while modified side uses
the new filename.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen merged commit 991422c into main Apr 15, 2026
@PureWeen PureWeen deleted the feat/codemirror-diff-editor branch April 15, 2026 16:17
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.

1 participant