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
94 changes: 94 additions & 0 deletions components/SnippetComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import { Calendar, Code2, User } from "lucide-react";
import {
buildSideBySideDiff,
ComparisonSnippet,
getContributor,
getMetadataDifferences,
tokenizeLine,
} from "@/lib/snippet-comparison";

const tokenColor = {
plain: "text-slate-200",
keyword: "text-fuchsia-300",
string: "text-emerald-300",
number: "text-amber-300",
comment: "text-slate-500 italic",
};

function HighlightedLine({ code, language }: { code?: string; language: string }) {
if (code === undefined) return <span aria-hidden="true">&nbsp;</span>;
return <>
{tokenizeLine(code, language).map((token, index) => (
<span className={tokenColor[token.kind]} key={`${index}-${token.value}`}>
{token.value}
</span>
))}
</>;
}

function Metadata({ snippet, differences }: {
snippet: ComparisonSnippet;
differences: ReturnType<typeof getMetadataDifferences>;
}) {
const item = (different: boolean) =>
`flex items-center gap-2 rounded-md border px-3 py-2 text-xs ${
different ? "border-amber-400/40 bg-amber-400/10 text-amber-100" : "border-white/10 bg-white/[.03] text-slate-400"
}`;
return (
<div className="grid gap-2 sm:grid-cols-3">
<div className={item(differences.language)}><Code2 className="h-3.5 w-3.5" /><span>{snippet.language}</span></div>
<div className={item(differences.createdAt)}><Calendar className="h-3.5 w-3.5" /><time>{new Date(snippet.created_at).toLocaleDateString()}</time></div>
<div className={item(differences.contributor)} title={getContributor(snippet)}><User className="h-3.5 w-3.5 shrink-0" /><span className="truncate">{getContributor(snippet)}</span></div>
</div>
);
}

export function SnippetComparison({ left, right }: { left: ComparisonSnippet; right: ComparisonSnippet }) {
const rows = buildSideBySideDiff(left.code, right.code);
const differences = getMetadataDifferences(left, right);
const pane = (side: "left" | "right", snippet: ComparisonSnippet) => (
<section className="min-w-0 overflow-hidden rounded-xl border border-white/10 bg-slate-950">
<div className="border-b border-white/10 p-4">
<h2 className="mb-3 truncate font-semibold text-white" title={snippet.title}>{snippet.title}</h2>
<Metadata snippet={snippet} differences={differences} />
</div>
<div className="overflow-x-auto" role="region" aria-label={`${snippet.title} code`}>
<pre className="min-w-max py-2 font-mono text-[13px] leading-6">
{rows.map((row, index) => {
const kind = row.kind;
const line = side === "left" ? row.left : row.right;
const number = side === "left" ? row.leftLine : row.rightLine;
const changed = kind === "changed";
const removed = side === "left" && kind === "removed";
const added = side === "right" && kind === "added";
return (
<div
key={index}
className={`flex min-h-6 ${changed ? "bg-amber-500/10" : removed ? "bg-rose-500/15" : added ? "bg-emerald-500/15" : ""}`}
>
<span className="w-12 shrink-0 select-none border-r border-white/5 pr-3 text-right text-slate-600">{number ?? ""}</span>
<code className="px-4"><HighlightedLine code={line} language={snippet.language} /></code>
</div>
);
})}
</pre>
</div>
</section>
);

return (
<div>
<div className="mb-4 flex flex-wrap gap-4 text-xs text-slate-400" aria-label="Difference legend">
<span><i className="mr-1.5 inline-block h-2.5 w-2.5 rounded-sm bg-rose-500/60" />Removed</span>
<span><i className="mr-1.5 inline-block h-2.5 w-2.5 rounded-sm bg-emerald-500/60" />Added</span>
<span><i className="mr-1.5 inline-block h-2.5 w-2.5 rounded-sm bg-amber-500/60" />Changed</span>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{pane("left", left)}
{pane("right", right)}
</div>
</div>
);
}
42 changes: 42 additions & 0 deletions lib/snippet-comparison.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
buildSideBySideDiff,
getMetadataDifferences,
tokenizeLine,
type ComparisonSnippet,
} from "./snippet-comparison";

const snippet = (overrides: Partial<ComparisonSnippet> = {}): ComparisonSnippet => ({
id: "one",
title: "Example",
code: "const answer = 42;",
language: "typescript",
created_at: "2026-01-01T00:00:00.000Z",
owner_wallet_address: "alice",
...overrides,
});

describe("snippet comparison", () => {
it("aligns equal, changed, added, and removed lines", () => {
const rows = buildSideBySideDiff("same\nold\nremoved", "same\nnew\nadded\nextra");
expect(rows.map((row) => row.kind)).toEqual(["equal", "changed", "changed", "added"]);
expect(rows[1]).toMatchObject({ left: "old", right: "new", leftLine: 2, rightLine: 2 });
expect(rows[3]).toMatchObject({ right: "extra", rightLine: 4 });
});

it("marks language, timestamp, and contributor differences", () => {
expect(getMetadataDifferences(
snippet(),
snippet({ language: "python", created_at: "2026-02-01T00:00:00.000Z", owner_wallet_address: "bob" }),
)).toEqual({ language: true, createdAt: true, contributor: true });
});

it("tokenizes keywords, strings, numbers, and comments", () => {
const tokens = tokenizeLine('const value = "hi" + 2; // note', "typescript");
expect(tokens).toEqual(expect.arrayContaining([
expect.objectContaining({ value: "const", kind: "keyword" }),
expect.objectContaining({ value: '"hi"', kind: "string" }),
expect.objectContaining({ value: "2", kind: "number" }),
expect.objectContaining({ value: "// note", kind: "comment" }),
]));
});
});
114 changes: 114 additions & 0 deletions lib/snippet-comparison.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
export interface ComparisonSnippet {
id: string;
title: string;
code: string;
language: string;
created_at: string;
owner_wallet_address?: string | null;
contributor?: string | null;
}

export type DiffKind = "equal" | "changed" | "added" | "removed";

export interface DiffRow {
kind: DiffKind;
left?: string;
right?: string;
leftLine?: number;
rightLine?: number;
}

export interface CodeToken {
value: string;
kind: "plain" | "keyword" | "string" | "number" | "comment";
}

const KEYWORDS: Record<string, Set<string>> = {
javascript: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "new", "import", "export", "from", "async", "await", "try", "catch", "throw"]),
typescript: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "interface", "type", "enum", "implements", "extends", "public", "private", "import", "export", "from", "async", "await"]),
python: new Set(["def", "return", "if", "elif", "else", "for", "while", "class", "import", "from", "as", "try", "except", "raise", "with", "lambda", "async", "await", "True", "False", "None"]),
go: new Set(["package", "import", "func", "return", "if", "else", "for", "range", "type", "struct", "interface", "go", "defer", "var", "const"]),
rust: new Set(["fn", "let", "mut", "pub", "impl", "trait", "struct", "enum", "match", "if", "else", "for", "while", "loop", "use", "mod", "return", "async", "await"]),
};

const C_STYLE = new Set(["java", "csharp", "cpp", "php"]);
const C_KEYWORDS = new Set(["class", "public", "private", "protected", "static", "void", "int", "string", "bool", "new", "return", "if", "else", "for", "while", "try", "catch", "throw", "namespace", "using"]);

export function buildSideBySideDiff(leftCode: string, rightCode: string): DiffRow[] {
const left = leftCode.split("\n");
const right = rightCode.split("\n");
const table = Array.from({ length: left.length + 1 }, () =>
Array<number>(right.length + 1).fill(0),
);

for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
table[i][j] = left[i] === right[j]
? table[i + 1][j + 1] + 1
: Math.max(table[i + 1][j], table[i][j + 1]);
}
}

const rows: DiffRow[] = [];
let i = 0;
let j = 0;
let leftLine = 1;
let rightLine = 1;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
rows.push({ kind: "equal", left: left[i], right: right[j], leftLine, rightLine });
i += 1; j += 1; leftLine += 1; rightLine += 1;
} else if (
i < left.length && j < right.length &&
table[i + 1][j] === table[i][j + 1]
) {
rows.push({ kind: "changed", left: left[i], right: right[j], leftLine, rightLine });
i += 1; j += 1; leftLine += 1; rightLine += 1;
} else if (j < right.length && (i === left.length || table[i][j + 1] >= table[i + 1][j])) {
rows.push({ kind: "added", right: right[j], rightLine });
j += 1; rightLine += 1;
} else {
rows.push({ kind: "removed", left: left[i], leftLine });
i += 1; leftLine += 1;
}
}
return rows;
}

export function getMetadataDifferences(left: ComparisonSnippet, right: ComparisonSnippet) {
return {
language: left.language.toLowerCase() !== right.language.toLowerCase(),
createdAt: new Date(left.created_at).getTime() !== new Date(right.created_at).getTime(),
contributor: getContributor(left) !== getContributor(right),
};
}

export function getContributor(snippet: ComparisonSnippet) {
return snippet.contributor || snippet.owner_wallet_address || "Unknown contributor";
}

export function tokenizeLine(line: string, language: string): CodeToken[] {
const keywords = KEYWORDS[language.toLowerCase()] ||
(C_STYLE.has(language.toLowerCase()) ? C_KEYWORDS : new Set<string>());
const commentMarker = ["python", "ruby"].includes(language.toLowerCase()) ? "#" : "//";
const pattern = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b\d+(?:\.\d+)?\b|\b[A-Za-z_$][\w$]*\b)/g;
const commentAt = line.indexOf(commentMarker);
const code = commentAt >= 0 ? line.slice(0, commentAt) : line;
const tokens: CodeToken[] = [];
let cursor = 0;

for (const match of code.matchAll(pattern)) {
const index = match.index ?? 0;
if (index > cursor) tokens.push({ value: code.slice(cursor, index), kind: "plain" });
const value = match[0];
const kind: CodeToken["kind"] =
value.startsWith('"') || value.startsWith("'") ? "string" :
/^\d/.test(value) ? "number" :
keywords.has(value) ? "keyword" : "plain";
tokens.push({ value, kind });
cursor = index + value.length;
}
if (cursor < code.length) tokens.push({ value: code.slice(cursor), kind: "plain" });
if (commentAt >= 0) tokens.push({ value: line.slice(commentAt), kind: "comment" });
return tokens.length ? tokens : [{ value: line, kind: "plain" }];
}