Skip to content

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

Open
PureWeen wants to merge 18 commits intomainfrom
feat/codemirror-diff-editor
Open

feat: CodeMirror 6 side-by-side diff editor#507
PureWeen wants to merge 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 #3)

CodeMirror 6 Side-by-Side Diff Editor

3 independent reviewers re-analyzed this PR after commit 0e1017bc ("fix: address all 6 PR review findings"). CI: ⚠️ No checks configured on this branch.


Previous Findings — Status (Re-review #2#3)

# Severity Finding Status
1 🔴 Apply writes blank placeholder lines to disk → data loss ✅ FIXED
2 🔴 Path traversal bypass (StartsWith without trailing separator) ✅ FIXED
3 🟡 Renamed file comment references wrong filename on original side ⚠️ PARTIALLY FIXED
4 🟡 _applySaveStatus bleeds across file switches ✅ FIXED
5 🟢 Uncached AddedLineCount/RemovedLineCount ✅ FIXED
6 🟢 Stale CSS comment on .cm-merge-revert ✅ FIXED

Details on Fixes

Finding 1 ✅: ReconstructOriginal/Modified now accept optional string[]? fileLines. When provided, inter-hunk gaps are filled with real file content. DiffView has a new FileContentProvider parameter, and the PR review panel wires it to LoadFileContentAsync. The Apply button writes back what the user actually edited — not fabricated blank lines. Both HandleApplyEditAsync and LoadFileContentAsync have matching path-traversal guards. Confirmed by all 3 reviewers.

Finding 2 ✅: Path check now uses:

var normalizedWorkDir = Path.GetFullPath(workDir)
    .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
    + Path.DirectorySeparatorChar;
if (!filePath.StartsWith(normalizedWorkDir, StringComparison.OrdinalIgnoreCase))

Applied consistently in both read and write paths. Confirmed by all 3 reviewers.

Finding 3 ⚠️ PARTIALLY FIXED: Table view now correctly uses OldFileName for original-side line comments on renamed files. However, the editor (CodeMirror) view still passes file.FileName (new name) to createMergeView, where commentMeta.fileName is shared by both sides. Clicking a line on the original side of a renamed file in editor mode still sends the new filename. See NEW-2 below.

Findings 4–6 ✅: All confirmed fixed by all 3 reviewers. SelectFile clears _applySaveStatus, line counts use ??= lazy caching, CSS comment updated.


New Findings (this re-review)

🟡 NEW-1 — Inline chat diffs missing FileContentProvider — Apply still writes blank gaps

File: ChatMessageItem.razor (DiffView instantiation)
Flagged by: 2/3 reviewers

The PR review panel correctly wires FileContentProvider="LoadFileContentAsync", but inline chat diffs in ChatMessageItem pass OnApplyEdit without FileContentProvider. If a user switches to Editor view on a multi-hunk chat diff and clicks Apply, inter-hunk gaps are still blank — the same data-loss class as the original Finding 1 but on a different code path.

Fix options:

  1. Pass a FileContentProvider to chat DiffView instances too (loads from Session.WorkingDirectory)
  2. Disable the Apply button when no FileContentProvider is set (safe default)
  3. Add a runtime guard: if FileContentProvider is null and file has multi-hunk gaps, refuse to Apply

🟡 NEW-2 — Editor-view line comment on renamed file still uses new filename (Finding 3 remainder)

File: DiffView.razor (InitCodeMirrorForFile), index.js (line click handler)
Flagged by: 2/3 reviewers

InitCodeMirrorForFile passes file.FileName (new name) to JS as commentMeta.fileName, which is used for both original and modified side clicks. Table view handles this correctly (uses OldFileName on original side), but editor view does not.

Fix: Pass both file.FileName and file.OldFileName ?? file.FileName to createMergeView; use old name for 'original' side clicks in JS.


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

Finding Reviewer Reason
_applySaveStatus not cleared on RawDiff change 1/3 Cosmetic edge case — status auto-clears in 2s and Apply button isn't rendered until editor mode re-entered
_prDiffCacheKey length-based collision 1/3 Theoretical — same PR, same diff length, different content is extremely rare
BeginLineComment spurious async / CS1998 1/3 Valid nit but compiler warning only
Path-traversal-blocked Apply shows "✅ Saved" 1/3 HandleApplyEditAsync returns without invoking callback on block — caller doesn't set "saved". Verified as false positive.
ReconstructOriginal gap-fill uses wrong file version 1/3 Display-only (original side read-only), no data path affected

Recommendation: ⚠️ Request Changes (minor)

5 of 6 previous findings are fully fixed — excellent iteration! 🎉 The two 🔴 CRITICAL issues (data loss + path traversal) are resolved.

Two 🟡 MODERATE items remain:

  1. NEW-1: Disable Apply (or pass FileContentProvider) on inline chat diffs to prevent the same data-loss bug on that code path
  2. NEW-2 / Finding 3 remainder: Pass OldFileName to JS for editor-view renamed-file comments

Neither is a show-stopper, but both are real user-facing bugs that will be hit in normal usage. A quick follow-up commit addressing these two would earn a ✅ Approve.

@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>
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