diff --git a/docs/api.md b/docs/api.md index d6015c0..36e9b2f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -117,7 +117,7 @@ Response: { ``` #### `GET /api/sessions/{id}/file-diff?path=...&context=4` -Compute a unified diff for a single file against its session baseline snapshot. Returns structured hunks with line numbers for GitHub PR-style rendering. +Compute a unified diff for a single file against its session baseline snapshot. Returns structured hunks with line numbers for GitHub PR-style rendering. For markdown files (`.md`, `.markdown`) the response also carries `markdown_content` — the post-change file content (original content for deleted files) used by the UI's rendered-preview toggle — plus `markdown_truncated` when it was cut at the diff line limit. Both are `null`/`false` for other file types. ```json Response: { @@ -136,7 +136,9 @@ Response: { ] } ], - "truncated": false + "truncated": false, + "markdown_content": null, + "markdown_truncated": false } ``` diff --git a/nerve/gateway/diff.py b/nerve/gateway/diff.py index aa50be8..67b2376 100644 --- a/nerve/gateway/diff.py +++ b/nerve/gateway/diff.py @@ -21,6 +21,15 @@ # Truncate diffs beyond this many output lines to keep payloads bounded. MAX_DIFF_LINES = 2000 +# Files with these suffixes get a rendered-markdown preview toggle in the UI; +# their diff response carries the preview source alongside the patch. +MARKDOWN_SUFFIXES = (".md", ".markdown") + + +def is_markdown_file(file_path: str) -> bool: + """True when the path looks like a markdown document.""" + return file_path.lower().endswith(MARKDOWN_SUFFIXES) + # ------------------------------------------------------------------ # # Public API # @@ -51,22 +60,26 @@ def compute_file_diff( # New file if original_content is None and current_content is not None: - return _make_new_file_diff(current_content, file_path, short) + diff = _make_new_file_diff(current_content, file_path, short) # Deleted file - if current_content is None and original_content is not None: - return _make_deleted_file_diff(original_content, file_path, short) + elif current_content is None and original_content is not None: + diff = _make_deleted_file_diff(original_content, file_path, short) # Both missing — shouldn't happen, treat as unchanged - if original_content is None and current_content is None: - return _empty_diff(file_path, short, "unchanged") + elif original_content is None and current_content is None: + diff = _empty_diff(file_path, short, "unchanged") # Both exist — real diff - assert original_content is not None and current_content is not None - if original_content == current_content: - return _empty_diff(file_path, short, "unchanged") + elif original_content == current_content: + diff = _empty_diff(file_path, short, "unchanged") + + else: + assert original_content is not None and current_content is not None + diff = _compute_difflib(original_content, current_content, file_path, short, context_lines) - return _compute_difflib(original_content, current_content, file_path, short, context_lines) + _attach_markdown_preview(diff, file_path, original_content, current_content) + return diff def compute_quick_stats( @@ -107,6 +120,33 @@ def shorten_path(file_path: str, workspace: str | None = None) -> str: # Internal helpers # # ------------------------------------------------------------------ # +def _attach_markdown_preview( + diff: dict[str, Any], + file_path: str, + original_content: str | None, + current_content: str | None, +) -> None: + """Add the rendered-preview source for markdown files. + + ``markdown_content`` is the post-change content (what the file looks like + now), falling back to the original for deleted files so the preview can + still show what was removed. Always ``None`` for non-markdown files. + Truncated at ``MAX_DIFF_LINES`` lines to keep payloads bounded, mirroring + the patch truncation. + """ + content: str | None = None + truncated = False + if is_markdown_file(file_path): + content = current_content if current_content is not None else original_content + if content is not None: + lines = content.splitlines() + if len(lines) > MAX_DIFF_LINES: + content = "\n".join(lines[:MAX_DIFF_LINES]) + truncated = True + diff["markdown_content"] = content + diff["markdown_truncated"] = truncated + + def _shorten_path(file_path: str, workspace: str | None = None) -> str: """Strip workspace prefix for display.""" if workspace: diff --git a/tests/test_diff.py b/tests/test_diff.py index bf27686..e3b370b 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -117,3 +117,39 @@ def test_large_created_file_truncated_flag(): d = compute_file_diff(None, content, "/ws/big.txt", workspace="/ws") assert d["truncated"] is True assert d["patch"] # still produced + + +# ------------------------------------------------------------------ # +# Markdown preview content (rendered-view toggle in the diff panel) # +# ------------------------------------------------------------------ # + +def test_markdown_file_carries_preview_content(): + curr = "# Title\n\nSome **bold** text.\n" + d = compute_file_diff("# Old title\n", curr, "/ws/notes.md", workspace="/ws") + assert d["markdown_content"] == curr + assert d["markdown_truncated"] is False + + +def test_markdown_suffix_is_case_insensitive(): + d = compute_file_diff(None, "# New\n", "/ws/README.MD", workspace="/ws") + assert d["markdown_content"] == "# New\n" + + +def test_deleted_markdown_previews_original_content(): + d = compute_file_diff("# Gone\n\nbye\n", None, "/ws/gone.md", workspace="/ws") + assert d["status"] == "deleted" + assert d["markdown_content"] == "# Gone\n\nbye\n" + + +def test_non_markdown_file_has_no_preview_content(): + d = compute_file_diff("a\n", "b\n", "/ws/app.py", workspace="/ws") + assert d["markdown_content"] is None + assert d["markdown_truncated"] is False + + +def test_markdown_preview_truncated_at_max_lines(): + curr = "\n".join(f"line {i}" for i in range(MAX_DIFF_LINES + 100)) + d = compute_file_diff("old\n", curr, "/ws/big.md", workspace="/ws") + assert d["markdown_truncated"] is True + expected = "\n".join(f"line {i}" for i in range(MAX_DIFF_LINES)) + assert d["markdown_content"] == expected diff --git a/web/src/components/Chat/FileChangesPanel.tsx b/web/src/components/Chat/FileChangesPanel.tsx index c9836de..2216ca0 100644 --- a/web/src/components/Chat/FileChangesPanel.tsx +++ b/web/src/components/Chat/FileChangesPanel.tsx @@ -1,8 +1,10 @@ import { useState, useEffect, useRef, lazy, Suspense } from 'react'; -import { ArrowLeft, FilePlus, FileEdit, FileX, Loader2, RefreshCw, WrapText } from 'lucide-react'; +import { ArrowLeft, Eye, FilePlus, FileEdit, FileX, Loader2, RefreshCw, WrapText } from 'lucide-react'; import { useChatStore } from '../../stores/chatStore'; import { api } from '../../api/client'; import { SelectionToolbar } from './SelectionToolbar'; +import { MarkdownContent } from './MarkdownContent'; +import { MAX_DIFF_LINES } from '../../types/chat'; import type { FileDiff, ModifiedFileSummary } from '../../types/chat'; // The diff renderer pulls in @pierre/diffs + Shiki — only loaded when a file @@ -88,6 +90,8 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: ( const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [wrap, setWrap] = useState(() => localStorage.getItem(WRAP_STORAGE_KEY) === 'true'); + // Markdown files only: toggle between the raw diff and a rendered preview. + const [preview, setPreview] = useState(false); const containerRef = useRef(null); const toggleWrap = () => { @@ -100,6 +104,7 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: ( let cancelled = false; setLoading(true); setError(null); + setPreview(false); api.getFileDiff(activeSession, file.path) .then(data => { if (!cancelled) setDiff(data); }) .catch(e => { if (!cancelled) setError(String(e)); }) @@ -107,6 +112,9 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: ( return () => { cancelled = true; }; }, [activeSession, file.path]); + // Empty string is a valid (empty) markdown doc — check against null/undefined. + const canPreview = diff?.markdown_content != null; + const { fileName } = splitPath(file.short_path); const color = STATUS_COLOR[file.status] || 'text-text-muted'; @@ -129,16 +137,30 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: ( −{diff.stats.deletions} )} - +
+ {canPreview && ( + + )} + +
{file.short_path} @@ -156,15 +178,26 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: (
Failed to load diff: {error}
)} {diff && !loading && ( - - Loading diff… -
- } - > - - + preview && diff.markdown_content != null ? ( +
+ + {diff.markdown_truncated && ( +
+ Preview truncated at {MAX_DIFF_LINES} lines +
+ )} +
+ ) : ( + + Loading diff… + + } + > + + + ) )} diff --git a/web/src/types/chat.ts b/web/src/types/chat.ts index 8751b33..f73b3b7 100644 --- a/web/src/types/chat.ts +++ b/web/src/types/chat.ts @@ -182,6 +182,11 @@ export interface FileDiff { /** Raw git-style unified-diff string for the @pierre/diffs renderer. */ patch: string; truncated: boolean; + /** Markdown files only: post-change file content (original for deleted + * files) for the rendered-preview toggle. Null for non-markdown files. */ + markdown_content?: string | null; + /** True when markdown_content was cut at MAX_DIFF_LINES. */ + markdown_truncated?: boolean; } export interface ModifiedFileSummary {