Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -136,7 +136,9 @@ Response: {
]
}
],
"truncated": false
"truncated": false,
"markdown_content": null,
"markdown_truncated": false
}
```

Expand Down
58 changes: 49 additions & 9 deletions nerve/gateway/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 53 additions & 20 deletions web/src/components/Chat/FileChangesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -88,6 +90,8 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: (
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<HTMLDivElement>(null);

const toggleWrap = () => {
Expand All @@ -100,13 +104,17 @@ 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)); })
.finally(() => { if (!cancelled) setLoading(false); });
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';

Expand All @@ -129,16 +137,30 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: (
<span className="text-diff-del">&minus;{diff.stats.deletions}</span>
)}
</div>
<button
onClick={toggleWrap}
aria-pressed={wrap}
className={`ml-auto w-5 h-5 flex items-center justify-center cursor-pointer transition-colors ${
wrap ? 'text-accent' : 'text-text-faint hover:text-text-muted'
}`}
title={wrap ? 'Disable line wrapping' : 'Enable line wrapping'}
>
<WrapText size={13} />
</button>
<div className="ml-auto flex items-center gap-1">
{canPreview && (
<button
onClick={() => setPreview(p => !p)}
aria-pressed={preview}
className={`w-5 h-5 flex items-center justify-center cursor-pointer transition-colors ${
preview ? 'text-accent' : 'text-text-faint hover:text-text-muted'
}`}
title={preview ? 'Show raw diff' : 'Show rendered markdown'}
>
<Eye size={13} />
</button>
)}
<button
onClick={toggleWrap}
aria-pressed={wrap}
className={`w-5 h-5 flex items-center justify-center cursor-pointer transition-colors ${
wrap ? 'text-accent' : 'text-text-faint hover:text-text-muted'
}`}
title={wrap ? 'Disable line wrapping' : 'Enable line wrapping'}
>
<WrapText size={13} />
</button>
</div>
</div>
<div className="text-[11px] text-text-faint px-4 py-1 bg-bg-sunken border-b border-surface-raised">
{file.short_path}
Expand All @@ -156,15 +178,26 @@ function FileDetailView({ file, onBack }: { file: ModifiedFileSummary; onBack: (
<div className="px-4 py-4 text-[13px] text-hue-red">Failed to load diff: {error}</div>
)}
{diff && !loading && (
<Suspense
fallback={
<div className="flex items-center gap-2 justify-center py-8 text-[13px] text-text-faint">
<Loader2 size={14} className="animate-spin" /> Loading diff…
</div>
}
>
<DiffView diff={diff} wrap={wrap} />
</Suspense>
preview && diff.markdown_content != null ? (
<div className="px-4 py-3 text-[13px]">
<MarkdownContent content={diff.markdown_content} />
{diff.markdown_truncated && (
<div className="text-center py-3 mt-3 text-[11px] text-text-faint border-t border-border-subtle">
Preview truncated at {MAX_DIFF_LINES} lines
</div>
)}
</div>
) : (
<Suspense
fallback={
<div className="flex items-center gap-2 justify-center py-8 text-[13px] text-text-faint">
<Loader2 size={14} className="animate-spin" /> Loading diff…
</div>
}
>
<DiffView diff={diff} wrap={wrap} />
</Suspense>
)
)}
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions web/src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading