diff --git a/components/SnippetComparison.tsx b/components/SnippetComparison.tsx new file mode 100644 index 0000000..4eb247a --- /dev/null +++ b/components/SnippetComparison.tsx @@ -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 ; + return <> + {tokenizeLine(code, language).map((token, index) => ( + + {token.value} + + ))} + ; +} + +function Metadata({ snippet, differences }: { + snippet: ComparisonSnippet; + differences: ReturnType; +}) { + 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 ( +
+
{snippet.language}
+
+
{getContributor(snippet)}
+
+ ); +} + +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) => ( +
+
+

{snippet.title}

+ +
+
+
+          {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 (
+              
+ {number ?? ""} + +
+ ); + })} +
+
+
+ ); + + return ( +
+
+ Removed + Added + Changed +
+
+ {pane("left", left)} + {pane("right", right)} +
+
+ ); +} diff --git a/lib/snippet-comparison.test.ts b/lib/snippet-comparison.test.ts new file mode 100644 index 0000000..fb1cd8f --- /dev/null +++ b/lib/snippet-comparison.test.ts @@ -0,0 +1,42 @@ +import { + buildSideBySideDiff, + getMetadataDifferences, + tokenizeLine, + type ComparisonSnippet, +} from "./snippet-comparison"; + +const snippet = (overrides: Partial = {}): 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" }), + ])); + }); +}); diff --git a/lib/snippet-comparison.ts b/lib/snippet-comparison.ts new file mode 100644 index 0000000..0fcd640 --- /dev/null +++ b/lib/snippet-comparison.ts @@ -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> = { + 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(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()); + 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" }]; +}