feat(FN-683): add diff syntax highlighting with background colors
- Add highlightDiff utility React component for diff rendering - Add CSS styles for diff-add (green), diff-del (red), diff-hunk (blue) line highlighting - Integrate highlightDiff in TaskChangesTab for inline diffs - Integrate highlightDiff in ChangedFilesModal for file diffs - Add unit tests for highlightDiff utility
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { FileEdit, FileMinus, FilePlus, FileSymlink, FolderGit2, X } from "lucide-react";
|
||||
import { useChangedFiles } from "../hooks/useChangedFiles";
|
||||
import { highlightDiff } from "../utils/highlightDiff";
|
||||
import type { TaskFileDiff } from "../api";
|
||||
|
||||
interface ChangedFilesModalProps {
|
||||
@@ -121,7 +122,9 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
|
||||
</div>
|
||||
<div className="gm-diff-viewer">
|
||||
{selectedStat ? <pre className="gm-diff-stat">{selectedStat}</pre> : null}
|
||||
<pre className="gm-diff-patch">{selectedFile.diff || "No diff available"}</pre>
|
||||
<pre className="gm-diff-patch">
|
||||
<code>{highlightDiff(selectedFile.diff || "No diff available")}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FileCode, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
|
||||
import { fetchTaskDiff, type TaskDiff } from "../api";
|
||||
import { highlightDiff } from "../utils/highlightDiff";
|
||||
|
||||
interface TaskChangesTabProps {
|
||||
taskId: string;
|
||||
@@ -185,7 +186,7 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
{isExpanded && patch && (
|
||||
<div className="changes-file-content">
|
||||
<pre className="changes-diff-patch">
|
||||
<code>{patch}</code>
|
||||
<code>{highlightDiff(patch)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13073,21 +13073,41 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
/* Syntax highlighting for diff */
|
||||
.gm-diff-patch .diff-add,
|
||||
.changes-diff-patch .diff-add,
|
||||
.gm-diff-patch [data-prefix="+"],
|
||||
.changes-diff-patch [data-prefix="+"] {
|
||||
color: #3fb950;
|
||||
background-color: rgba(63, 185, 80, 0.12);
|
||||
}
|
||||
|
||||
.gm-diff-patch .diff-del,
|
||||
.changes-diff-patch .diff-del,
|
||||
.gm-diff-patch [data-prefix="-"],
|
||||
.changes-diff-patch [data-prefix="-"] {
|
||||
color: #f85149;
|
||||
background-color: rgba(248, 81, 73, 0.12);
|
||||
}
|
||||
|
||||
.gm-diff-patch .diff-hunk,
|
||||
.changes-diff-patch .diff-hunk,
|
||||
.gm-diff-patch [data-prefix="@@"],
|
||||
.changes-diff-patch [data-prefix="@@"] {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
/* Block display for highlighted diff lines */
|
||||
.gm-diff-patch .diff-add,
|
||||
.gm-diff-patch .diff-del,
|
||||
.gm-diff-patch .diff-hunk,
|
||||
.changes-diff-patch .diff-add,
|
||||
.changes-diff-patch .diff-del,
|
||||
.changes-diff-patch .diff-hunk {
|
||||
display: block;
|
||||
padding: 0 8px;
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
/* === Project Selector === */
|
||||
.project-selector {
|
||||
position: relative;
|
||||
|
||||
149
packages/dashboard/app/utils/highlightDiff.test.ts
Normal file
149
packages/dashboard/app/utils/highlightDiff.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { highlightDiff } from "./highlightDiff";
|
||||
import React from "react";
|
||||
|
||||
describe("highlightDiff", () => {
|
||||
it("applies diff-add class to added lines starting with +", () => {
|
||||
const result = highlightDiff("+hello world");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const span = result[0] as React.ReactElement;
|
||||
expect(span.type).toBe("span");
|
||||
expect(span.props.className).toBe("diff-add");
|
||||
expect(span.props.children).toBe("+hello world\n");
|
||||
});
|
||||
|
||||
it("applies diff-del class to removed lines starting with -", () => {
|
||||
const result = highlightDiff("-world");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const span = result[0] as React.ReactElement;
|
||||
expect(span.type).toBe("span");
|
||||
expect(span.props.className).toBe("diff-del");
|
||||
expect(span.props.children).toBe("-world\n");
|
||||
});
|
||||
|
||||
it("applies diff-hunk class to hunk headers starting with @@", () => {
|
||||
const result = highlightDiff("@@ -1,5 +1,6 @@ function");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const span = result[0] as React.ReactElement;
|
||||
expect(span.type).toBe("span");
|
||||
expect(span.props.className).toBe("diff-hunk");
|
||||
expect(span.props.children).toBe("@@ -1,5 +1,6 @@ function\n");
|
||||
});
|
||||
|
||||
it("does not apply special class to context lines", () => {
|
||||
const result = highlightDiff(" context line");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// Context lines should be returned as plain text fragments
|
||||
const fragment = result[0] as React.ReactElement;
|
||||
expect(fragment.type).toBe(React.Fragment);
|
||||
expect(fragment.props.children).toBe(" context line\n");
|
||||
});
|
||||
|
||||
it("does not apply diff-add class to +++ lines", () => {
|
||||
const result = highlightDiff("+++ b/file.ts");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const fragment = result[0] as React.ReactElement;
|
||||
expect(fragment.type).toBe(React.Fragment);
|
||||
expect(fragment.props.children).toBe("+++ b/file.ts\n");
|
||||
});
|
||||
|
||||
it("does not apply diff-del class to --- lines", () => {
|
||||
const result = highlightDiff("--- a/file.ts");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const fragment = result[0] as React.ReactElement;
|
||||
expect(fragment.type).toBe(React.Fragment);
|
||||
expect(fragment.props.children).toBe("--- a/file.ts\n");
|
||||
});
|
||||
|
||||
it("renders multiple lines correctly with different classes", () => {
|
||||
const diff = `diff --git a/file.ts b/file.ts
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
context line
|
||||
+added line
|
||||
-deleted line
|
||||
another context`;
|
||||
|
||||
const result = highlightDiff(diff);
|
||||
|
||||
expect(result).toHaveLength(8);
|
||||
|
||||
// Line 0: diff --git - plain fragment
|
||||
expect((result[0] as React.ReactElement).type).toBe(React.Fragment);
|
||||
|
||||
// Line 1: --- a/file.ts - plain fragment (not diff-del)
|
||||
expect((result[1] as React.ReactElement).type).toBe(React.Fragment);
|
||||
|
||||
// Line 2: +++ b/file.ts - plain fragment (not diff-add)
|
||||
expect((result[2] as React.ReactElement).type).toBe(React.Fragment);
|
||||
|
||||
// Line 3: @@ hunk header - diff-hunk
|
||||
const hunkLine = result[3] as React.ReactElement;
|
||||
expect(hunkLine.type).toBe("span");
|
||||
expect(hunkLine.props.className).toBe("diff-hunk");
|
||||
|
||||
// Line 4: context - plain fragment
|
||||
expect((result[4] as React.ReactElement).type).toBe(React.Fragment);
|
||||
|
||||
// Line 5: +added - diff-add
|
||||
const addedLine = result[5] as React.ReactElement;
|
||||
expect(addedLine.type).toBe("span");
|
||||
expect(addedLine.props.className).toBe("diff-add");
|
||||
|
||||
// Line 6: -deleted - diff-del
|
||||
const deletedLine = result[6] as React.ReactElement;
|
||||
expect(deletedLine.type).toBe("span");
|
||||
expect(deletedLine.props.className).toBe("diff-del");
|
||||
|
||||
// Line 7: another context - plain fragment
|
||||
expect((result[7] as React.ReactElement).type).toBe(React.Fragment);
|
||||
});
|
||||
|
||||
it("renders empty diff without errors", () => {
|
||||
const result = highlightDiff("");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// Empty string becomes single element with empty line
|
||||
const fragment = result[0] as React.ReactElement;
|
||||
expect(fragment.type).toBe(React.Fragment);
|
||||
expect(fragment.props.children).toBe("\n");
|
||||
});
|
||||
|
||||
it("handles single line without newline", () => {
|
||||
const result = highlightDiff("+single line");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const span = result[0] as React.ReactElement;
|
||||
expect(span.type).toBe("span");
|
||||
expect(span.props.className).toBe("diff-add");
|
||||
expect(span.props.children).toBe("+single line\n");
|
||||
});
|
||||
|
||||
it("handles diff header lines correctly", () => {
|
||||
const diff = `diff --git a/src/index.ts b/src/index.ts
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -10,6 +10,7 @@ export`;
|
||||
|
||||
const result = highlightDiff(diff);
|
||||
|
||||
// 5 lines total (split by \n)
|
||||
expect(result).toHaveLength(5);
|
||||
|
||||
// All header lines should be plain fragments, not diff-add/diff-del
|
||||
expect((result[0] as React.ReactElement).type).toBe(React.Fragment);
|
||||
expect((result[1] as React.ReactElement).type).toBe(React.Fragment);
|
||||
expect((result[2] as React.ReactElement).type).toBe(React.Fragment); // --- a/src/index.ts
|
||||
expect((result[3] as React.ReactElement).type).toBe(React.Fragment); // +++ b/src/index.ts
|
||||
expect((result[4] as React.ReactElement).type).toBe("span");
|
||||
expect((result[4] as React.ReactElement).props.className).toBe("diff-hunk");
|
||||
});
|
||||
});
|
||||
42
packages/dashboard/app/utils/highlightDiff.tsx
Normal file
42
packages/dashboard/app/utils/highlightDiff.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Highlights diff output by wrapping each line in a <span> with appropriate CSS classes.
|
||||
*
|
||||
* - Lines starting with `+` (but not `+++`) → class `diff-add`
|
||||
* - Lines starting with `-` (but not `---`) → class `diff-del`
|
||||
* - Lines starting with `@@` → class `diff-hunk`
|
||||
* - Context lines and other diff headers → no special class
|
||||
*
|
||||
* @param diff - The raw diff string to highlight
|
||||
* @returns An array of React nodes with appropriate span wrappers
|
||||
*/
|
||||
export function highlightDiff(diff: string): React.ReactNode[] {
|
||||
const lines = diff.split("\n");
|
||||
|
||||
return lines.map((line, index) => {
|
||||
// Determine the class for this line based on its prefix
|
||||
let className = "";
|
||||
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
className = "diff-add";
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
className = "diff-del";
|
||||
} else if (line.startsWith("@@")) {
|
||||
className = "diff-hunk";
|
||||
}
|
||||
|
||||
// Wrap in a span if we have a class, otherwise return the line as-is
|
||||
if (className) {
|
||||
return (
|
||||
<span key={index} className={className}>
|
||||
{`${line}\n`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For lines without special classes, we still need to add the newline
|
||||
// We use a fragment to avoid unnecessary wrapper elements
|
||||
return <React.Fragment key={index}>{`${line}\n`}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user