Skip to content
Open
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
27 changes: 20 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@types/bun": "^1.2.0",
"@types/react": "^19.2.14",
"@vitest/coverage-v8": "^4.0.18",
"emphasize": "^7.0.0",
"fast-check": "^3.0.0",
"ink": "6.7.0",
"ink-testing-library": "^4.0.0",
Expand Down
169 changes: 169 additions & 0 deletions src/components/DiffLine.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { render } from "ink-testing-library";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import type { DisplayLine } from "../utils/formatDiff.js";
import * as highlightCodeModule from "../utils/highlightCode.js";
import { renderDiffLine } from "./DiffLine.js";

function renderLine(line: DisplayLine, isCursor = false): string {
const { lastFrame } = render(<>{renderDiffLine(line, isCursor)}</>);
return lastFrame() ?? "";
}

describe("renderDiffLine with syntax highlighting", () => {
it("highlights add line with known language", () => {
const line: DisplayLine = {
type: "add",
text: "+const x = 42;",
filePath: "src/index.ts",
};
const output = renderLine(line);
expect(output).toContain("+");
expect(output).toContain("const");
expect(output).toContain("42");
});

it("highlights delete line with known language", () => {
const line: DisplayLine = {
type: "delete",
text: "-let y = 0;",
filePath: "src/index.ts",
};
const output = renderLine(line);
expect(output).toContain("-");
expect(output).toContain("let");
});

it("highlights context line with known language", () => {
const line: DisplayLine = {
type: "context",
text: " return x;",
filePath: "src/index.ts",
};
const output = renderLine(line);
expect(output).toContain("return");
});

it("falls back for unknown file extensions", () => {
const line: DisplayLine = {
type: "add",
text: "+some data",
filePath: "data.xyz",
};
const output = renderLine(line);
expect(output).toContain("+some data");
});

it("falls back when no filePath", () => {
const line: DisplayLine = {
type: "add",
text: "+hello",
};
const output = renderLine(line);
expect(output).toContain("+hello");
});

it("applies bold when cursor is active", () => {
const line: DisplayLine = {
type: "add",
text: "+const x = 1;",
filePath: "src/app.ts",
};
const output = renderLine(line, true);
expect(output).toContain("const");
});

it("renders context line without prefixColor", () => {
const line: DisplayLine = {
type: "context",
text: " const y = 2;",
filePath: "src/app.ts",
};
const output = renderLine(line);
expect(output).toContain("const");
expect(output).toContain("2");
});

it("handles empty content after prefix", () => {
const line: DisplayLine = {
type: "add",
text: "+",
filePath: "src/app.ts",
};
const output = renderLine(line);
expect(output).toContain("+");
});

it("highlights Python code", () => {
const line: DisplayLine = {
type: "add",
text: "+def hello():",
filePath: "main.py",
};
const output = renderLine(line);
expect(output).toContain("def");
});

it("highlights JSON", () => {
const line: DisplayLine = {
type: "context",
text: ' "name": "test"',
filePath: "package.json",
};
const output = renderLine(line);
expect(output).toContain("name");
});

it("renders segments with bold and dim attributes", () => {
const line: DisplayLine = {
type: "add",
text: "+import React from 'react';",
filePath: "src/app.tsx",
};
const output = renderLine(line);
expect(output).toContain("import");
expect(output).toContain("React");
});

it("renders non-highlighted line types unchanged", () => {
const header: DisplayLine = { type: "header", text: "src/auth.ts" };
expect(renderLine(header)).toContain("src/auth.ts");

const sep: DisplayLine = { type: "separator", text: "──────" };
expect(renderLine(sep)).toContain("──────");

const trunc: DisplayLine = { type: "truncation", text: "[t] show more" };
expect(renderLine(trunc)).toContain("[t] show more");
});

it("renders segments with bold, dim, and italic attributes", () => {
const spy = vi.spyOn(highlightCodeModule, "highlightLine").mockReturnValueOnce([
{ text: "/* ", dim: true },
{ text: "important", bold: true },
{ text: "comment", italic: true },
{ text: " */", dim: true },
]);
const line: DisplayLine = {
type: "context",
text: " /* comment */",
filePath: "src/index.ts",
};
const output = renderLine(line);
expect(output).toContain("comment");
spy.mockRestore();
});

it("renders single plain segment without wrapping", () => {
const spy = vi
.spyOn(highlightCodeModule, "highlightLine")
.mockReturnValueOnce([{ text: "plain text" }]);
const line: DisplayLine = {
type: "add",
text: "+plain text",
filePath: "src/index.ts",
};
const output = renderLine(line);
expect(output).toContain("plain text");
spy.mockRestore();
});
});
63 changes: 52 additions & 11 deletions src/components/DiffLine.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,55 @@
import { Text } from "ink";
import React from "react";
import type { DisplayLine } from "../utils/formatDiff.js";
import { detectLanguage, type HighlightSegment, highlightLine } from "../utils/highlightCode.js";

function renderSegments(segments: HighlightSegment[]): React.ReactNode {
if (segments.length === 1 && !segments[0]?.color && !segments[0]?.bold && !segments[0]?.dim) {
/* v8 ignore next -- segments always have at least one element here */
return segments[0]?.text ?? "";
}
let offset = 0;
return segments.map((seg) => {
const key = `s${offset}`;
offset += seg.text.length;
return (
<Text
key={key}
{...(seg.color ? { color: seg.color } : {})}
{...(seg.bold ? { bold: true } : {})}
{...(seg.dim ? { dimColor: true } : {})}
{...(seg.italic ? { italic: true } : {})}
>
{seg.text}
</Text>
);
});
}

function renderCodeLine(
line: DisplayLine,
prefixColor: string | undefined,
bold: boolean,
): React.ReactNode {
const prefix = line.text.slice(0, 1);
const content = line.text.slice(1);
const lang = line.filePath ? detectLanguage(line.filePath) : undefined;

if (lang && content) {
const segments = highlightLine(content, lang);
return (
<Text bold={bold}>
{prefixColor ? <Text color={prefixColor}>{prefix}</Text> : <Text>{prefix}</Text>}
{renderSegments(segments)}
</Text>
);
}
return (
<Text {...(prefixColor ? { color: prefixColor } : {})} bold={bold}>
{line.text}
</Text>
);
}

export function renderDiffLine(line: DisplayLine, isCursor = false): React.ReactNode {
const bold = isCursor;
Expand All @@ -14,19 +63,11 @@ export function renderDiffLine(line: DisplayLine, isCursor = false): React.React
case "separator":
return <Text dimColor>{line.text}</Text>;
case "add":
return (
<Text color="green" bold={bold}>
{line.text}
</Text>
);
return renderCodeLine(line, "green", bold);
case "delete":
return (
<Text color="red" bold={bold}>
{line.text}
</Text>
);
return renderCodeLine(line, "red", bold);
case "context":
return <Text bold={bold}>{line.text}</Text>;
return renderCodeLine(line, undefined, bold);
case "truncate-context":
return <Text dimColor>{line.text}</Text>;
case "truncation":
Expand Down
Loading