FABRIC/src/tui/components/DiffView.ts
jedarden 52ab686fee docs(bf-48nk): close genesis bead - all gaps already implemented
Verified all implementation gaps from the genesis bead checklist are complete:
- memoryProfiler.ts: Fully implemented with snapshot tracking and V8 heap dumps
- FileHeatmap treemap + timelapse: Both views fully implemented with playback controls
- SpanDag zoom/pan: Fully implemented with wheel zoom and drag-to-pan

All 2399 tests pass (4 skipped).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:21:34 -04:00

334 lines
7.4 KiB
TypeScript

/**
* DiffView Component
*
* Renders unified diffs from Edit tool calls.
* Shows additions in green, deletions in red, with line numbers.
*/
import blessed from 'blessed';
import { colors } from '../utils/colors.js';
export interface DiffViewOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position from top */
top: number | string;
/** Position from left */
left: number | string;
/** Width of the panel */
width: number | string;
/** Height of the panel */
height: number | string;
/** Maximum lines to show before truncation */
maxLines?: number;
}
export interface DiffLine {
/** Line type: added, removed, context, header */
type: 'added' | 'removed' | 'context' | 'header';
/** Original line number (for removed/context) */
oldLine?: number;
/** New line number (for added/context) */
newLine?: number;
/** Line content */
content: string;
}
export interface DiffHunk {
/** File path being diffed */
path: string;
/** Diff lines */
lines: DiffLine[];
/** Whether this is truncated */
truncated?: boolean;
}
/**
* Parse unified diff format into structured lines
*/
export function parseDiff(diffText: string): DiffLine[] {
const lines: DiffLine[] = [];
// Handle empty input - split returns [''] for empty string
if (diffText === '') {
return lines;
}
const rawLines = diffText.split('\n');
let oldLineNum = 1;
let newLineNum = 1;
for (const line of rawLines) {
// Hunk header @@ -a,b +c,d @@
if (line.startsWith('@@')) {
const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match) {
oldLineNum = parseInt(match[1], 10);
newLineNum = parseInt(match[2], 10);
}
lines.push({ type: 'header', content: line });
continue;
}
// File header
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('diff')) {
lines.push({ type: 'header', content: line });
continue;
}
// Context line
if (line.startsWith(' ') || line === '') {
lines.push({
type: 'context',
oldLine: oldLineNum++,
newLine: newLineNum++,
content: line.slice(1),
});
continue;
}
// Added line
if (line.startsWith('+')) {
lines.push({
type: 'added',
newLine: newLineNum++,
content: line.slice(1),
});
continue;
}
// Removed line
if (line.startsWith('-')) {
lines.push({
type: 'removed',
oldLine: oldLineNum++,
content: line.slice(1),
});
continue;
}
// Other lines (e.g., index, mode changes)
lines.push({ type: 'context', content: line });
}
return lines;
}
/**
* Format a single diff line for blessed display
*/
function formatDiffLine(line: DiffLine, width: number): string {
const maxContentWidth = width - 12; // Account for line numbers and padding
switch (line.type) {
case 'header':
return `{cyan-fg}${line.content.slice(0, maxContentWidth)}{/}`;
case 'added':
const addedNum = line.newLine?.toString().padStart(4) || ' ';
return `{green-fg}+${addedNum} ${line.content.slice(0, maxContentWidth)}{/}`;
case 'removed':
const removedNum = line.oldLine?.toString().padStart(4) || ' ';
return `{red-fg}-${removedNum} ${line.content.slice(0, maxContentWidth)}{/}`;
case 'context':
const oldNum = line.oldLine?.toString().padStart(4) || ' ';
const newNum = line.newLine?.toString().padStart(4) || ' ';
const truncatedContent = line.content.slice(0, maxContentWidth - 10);
return `{gray-fg} ${oldNum} ${newNum} ${truncatedContent}{/}`;
default:
return line.content;
}
}
/**
* DiffView displays inline diffs from Edit tool calls
*/
export class DiffView {
private box: blessed.Widgets.BoxElement;
private currentHunk: DiffHunk | null = null;
private maxLines: number;
constructor(options: DiffViewOptions) {
this.maxLines = options.maxLines || 50;
this.box = blessed.box({
parent: options.parent,
tags: true,
top: options.top,
left: options.left,
width: options.width,
height: options.height,
label: ' Diff View ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.header },
},
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
hidden: true,
});
}
/**
* Set the diff to display
*/
setDiff(path: string, diffText: string): void {
const lines = parseDiff(diffText);
const truncated = lines.length > this.maxLines;
this.currentHunk = {
path,
lines: truncated ? lines.slice(0, this.maxLines) : lines,
truncated,
};
this.render();
}
/**
* Set diff from Edit tool parameters
*/
setEditDiff(path: string, oldString: string, newString: string): void {
// Generate a simple unified diff
const diff = this.generateSimpleDiff(path, oldString, newString);
this.setDiff(path, diff);
}
/**
* Generate a simple unified diff from old/new strings
*/
private generateSimpleDiff(path: string, oldString: string, newString: string): string {
const oldLines = oldString.split('\n');
const newLines = newString.split('\n');
let diff = `--- a/${path}\n+++ b/${path}\n@@ -1,${oldLines.length} +1,${newLines.length} @@\n`;
// Show removed lines
for (const line of oldLines) {
diff += `-${line}\n`;
}
// Show added lines
for (const line of newLines) {
diff += `+${line}\n`;
}
return diff;
}
/**
* Render the current diff
*/
render(): void {
if (!this.currentHunk) {
this.box.setContent('{gray-fg}No diff to display{/}');
this.box.screen.render();
return;
}
const hunk = this.currentHunk;
const width = (this.box.width as number) - 2; // Account for border
const lines: string[] = [];
// Header with file path
lines.push(`{bold}${hunk.path}{/}`);
lines.push('{gray-fg}─────────────────────────────────────{/}');
lines.push('');
// Diff lines
for (const line of hunk.lines) {
lines.push(formatDiffLine(line, width));
}
// Truncation notice
if (hunk.truncated) {
lines.push('');
lines.push('{yellow-fg}... truncated (press Enter to expand){/}');
}
this.box.setContent(lines.join('\n'));
this.box.screen.render();
}
/**
* Show the diff view
*/
show(): void {
this.box.show();
this.box.screen.render();
}
/**
* Hide the diff view
*/
hide(): void {
this.box.hide();
this.box.screen.render();
}
/**
* Toggle visibility
*/
toggle(): void {
if (this.box.hidden) {
this.show();
} else {
this.hide();
}
}
/**
* Check if visible
*/
isVisible(): boolean {
return !this.box.hidden;
}
/**
* Get current hunk
*/
getHunk(): DiffHunk | null {
return this.currentHunk;
}
/**
* Clear the diff
*/
clear(): void {
this.currentHunk = null;
this.box.setContent('{gray-fg}No diff to display{/}');
this.box.screen.render();
}
/**
* Focus this component
*/
focus(): void {
this.box.focus();
}
/**
* Get the underlying blessed element
*/
getElement(): blessed.Widgets.BoxElement {
return this.box;
}
}
export default DiffView;