Something went wrong. Try again.
Node.js & Bun bindings for Zed’s GPUI. Build memory efficient native apps with React or Solid.js
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906/** * GPUIX Diff Viewer Example * * Ported from: https://github.com/remorses/critique/blob/87fba2b/src/diff.tsx * * Port of critique's diff.tsx from opentui to GPUIX. * Renders unified or split diff views with syntax highlighting (shiki) * and word-level diff highlights inside a scrollable container. * * Key differences from opentui version: * opentui <box> → GPUIX <div> * opentui <span fg> → GPUIX <text style={{ color }}> (inside flex-row div) * opentui <span bg> → GPUIX <text style={{ backgroundColor }}> * opentui RGBA.fromInts → hex string "#RRGGBBAA" * opentui onMouse → GPUIX onClick * * Run with: cd examples && bun run diff * Test with: cd examples && bun run test */
import React, { useState } from "react"import { render } from "@gpuix/react"import { diffWords } from "diff"import { createHighlighter, type BundledLanguage, type GrammarState, type ThemedToken,} from "shiki"import type { StructuredPatchHunk as Hunk } from "diff"
// ── Color constants ──────────────────────────────────────────────────// All colors as hex strings (GPUIX parses #RRGGBB, #RRGGBBAA, and rgba())
const UNCHANGED_CODE_BG = "rgba(15, 15, 15, 1)"const ADDED_BG = "rgba(100, 250, 120, 0.047)"const REMOVED_BG = "rgba(255, 0, 0, 0.125)"
const LINE_NUMBER_BG = "rgba(5, 5, 5, 1)"const REMOVED_LINE_NUMBER_BG = "rgba(60, 0, 0, 1)"const ADDED_LINE_NUMBER_BG = "rgba(0, 50, 0, 1)"const LINE_NUMBER_FG_BRIGHT = "#ffffff"const LINE_NUMBER_FG_DIM = "#6c7086"
// Word diff highlight backgroundsconst WORD_REMOVED_BG = "rgba(255, 50, 50, 0.39)"const WORD_ADDED_BG = "rgba(0, 200, 0, 0.39)"
// Default code foreground — matches github-dark-default theme.// Used for text that doesn't have syntax highlighting (word diff, fallbacks).const CODE_FG = "#e6edf3"
// Hunk separatorconst SEPARATOR_FG = "#6c7086"
// ── Shiki highlighter ────────────────────────────────────────────────
const theme = "github-dark-default"const highlighter = await createHighlighter({ themes: [theme], langs: [ "javascript", "typescript", "tsx", "jsx", "json", "markdown", "html", "css", "python", "rust", "go", "java", "c", "cpp", "yaml", "toml", "bash", "sh", "sql", ],})
// ── Language detection ────────────────────────────────────────────────
function detectLanguage(filePath: string): BundledLanguage { const ext = filePath.split(".").pop()?.toLowerCase() switch (ext) { case "ts": return "typescript" case "tsx": return "tsx" case "jsx": return "jsx" case "js": case "mjs": case "cjs": return "javascript" case "json": return "json" case "md": case "mdx": case "markdown": return "markdown" case "html": case "htm": return "html" case "css": return "css" case "py": return "python" case "rs": return "rust" case "go": return "go" case "java": return "java" case "c": case "h": return "c" case "cpp": case "cc": case "cxx": case "hpp": case "hxx": return "cpp" case "yaml": case "yml": return "yaml" case "toml": return "toml" case "sh": return "sh" case "bash": return "bash" case "sql": return "sql" default: return "javascript" }}
// ── Levenshtein / similarity ─────────────────────────────────────────
function levenshteinDistance(str1: string, str2: string): number { const len1 = str1.length const len2 = str2.length const matrix: number[][] = []
for (let i = 0; i <= len1; i++) { matrix[i] = [i] } for (let j = 0; j <= len2; j++) { matrix[0]![j] = j } for (let i = 1; i <= len1; i++) { for (let j = 1; j <= len2; j++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1 matrix[i]![j] = Math.min( matrix[i - 1]![j]! + 1, matrix[i]![j - 1]! + 1, matrix[i - 1]![j - 1]! + cost, ) } } return matrix[len1]![len2]!}
function calculateSimilarity(str1: string, str2: string): number { const longer = str1.length > str2.length ? str1 : str2 const shorter = str1.length > str2.length ? str2 : str1 if (longer.length === 0) return 1.0 const editDistance = levenshteinDistance(longer, shorter) return (longer.length - editDistance) / longer.length}
// ── Token rendering ──────────────────────────────────────────────────// In GPUIX, each token is a <text> with its own color inside a flex-row <div>.// This replaces opentui's <text><span fg={color}>...</span></text> pattern.
function HighlightedTokens({ tokens }: { tokens: ThemedToken[] }) { return ( <div style={{ display: "flex", flexDirection: "row" }}> {tokens.map((token, i) => ( <text key={i} style={{ color: token.color || "#e6edf3", whiteSpace: "nowrap", }} > {token.content} </text> ))} </div> )}
// Word-diff tokens — shows which words changed within a line.// Changed words get a background highlight (red for removed, green for added).
function WordDiffTokens({ parts, mode,}: { parts: ReturnType<typeof diffWords> mode: "remove" | "add"}) { const highlightBg = mode === "remove" ? WORD_REMOVED_BG : WORD_ADDED_BG return ( <div style={{ display: "flex", flexDirection: "row" }}> {parts.map((part, i) => { // Show removed parts only in remove mode, added parts only in add mode if (mode === "remove" && part.added) return null if (mode === "add" && part.removed) return null
const isHighlighted = (mode === "remove" && part.removed) || (mode === "add" && part.added)
return ( <text key={i} style={{ color: CODE_FG, whiteSpace: "nowrap", ...(isHighlighted ? { backgroundColor: highlightBg } : {}), }} > {part.value} </text> ) })} </div> )}
// ── Line number gutter ───────────────────────────────────────────────
function LineNumberGutter({ lineNumber, type, maxWidth,}: { lineNumber: string type: string maxWidth: number}) { const bg = type === "add" ? ADDED_LINE_NUMBER_BG : type === "remove" ? REMOVED_LINE_NUMBER_BG : LINE_NUMBER_BG
const fg = type === "add" || type === "remove" ? LINE_NUMBER_FG_BRIGHT : LINE_NUMBER_FG_DIM
return ( <div style={{ flexShrink: 0, alignSelf: "stretch", backgroundColor: bg, }} > <text style={{ color: fg, whiteSpace: "nowrap", fontSize: 13, }} > {` ${lineNumber.padStart(maxWidth)} `} </text> </div> )}
// ── Structured diff processing ───────────────────────────────────────// Processes raw diff lines into renderable elements with syntax highlighting,// line pairing, and word-level diff when lines are similar enough.
interface DiffLineData { code: React.ReactNode type: string oldLineNumber: string newLineNumber: string pairedWith?: number key: string}
function processHunkLines( lines: string[], oldStart: number, filePath: string,): DiffLineData[] { const processedLines = lines.map((code) => { if (code.startsWith("+")) { return { code: code.slice(1), type: "add" as const } } if (code.startsWith("-")) { return { code: code.slice(1), type: "remove" as const } } return { code: code.slice(1), type: "nochange" as const } })
const lang = detectLanguage(filePath)
// Tokenize "before" lines (removed + unchanged) let beforeState: GrammarState | undefined const beforeTokens: (ThemedToken[] | null)[] = [] for (const line of processedLines) { if (line.type === "remove" || line.type === "nochange") { const result = highlighter.codeToTokens(line.code, { lang, theme, grammarState: beforeState, }) beforeTokens.push(result.tokens[0] || null) beforeState = highlighter.getLastGrammarState(result.tokens) } else { beforeTokens.push(null) } }
// Tokenize "after" lines (added + unchanged) let afterState: GrammarState | undefined const afterTokens: (ThemedToken[] | null)[] = [] for (const line of processedLines) { if (line.type === "add" || line.type === "nochange") { const result = highlighter.codeToTokens(line.code, { lang, theme, grammarState: afterState, }) afterTokens.push(result.tokens[0] || null) afterState = highlighter.getLastGrammarState(result.tokens) } else { afterTokens.push(null) } }
// Pair consecutive removed/added lines for word-level diff const hasRemovals = processedLines.some((l) => l.type === "remove") const hasAdditions = processedLines.some((l) => l.type === "add") const shouldShowWordDiff = hasRemovals && hasAdditions
const linePairs: Array<{ remove?: number; add?: number }> = [] if (shouldShowWordDiff) { let i = 0 while (i < processedLines.length) { if (processedLines[i]?.type === "remove") { const removes: number[] = [] let j = i while (j < processedLines.length && processedLines[j]?.type === "remove") { removes.push(j) j++ } const adds: number[] = [] while (j < processedLines.length && processedLines[j]?.type === "add") { adds.push(j) j++ } const minLen = Math.min(removes.length, adds.length) for (let k = 0; k < minLen; k++) { linePairs.push({ remove: removes[k], add: adds[k] }) } i = j } else { i++ } } }
// Build result lines with syntax highlighting and word diff let oldLineNumber = oldStart let newLineNumber = oldStart const result: Array<{ code: React.ReactNode type: string oldLineNumber: number newLineNumber: number pairedWith?: number }> = []
for (let i = 0; i < processedLines.length; i++) { const line = processedLines[i] if (!line) continue
const { code, type } = line const pair = linePairs.find((p) => p.remove === i || p.add === i)
if (pair && pair.remove === i && pair.add !== undefined) { // Removed line paired with an addition — try word diff const removedText = processedLines[i]?.code const addedLine = processedLines[pair.add] if (!removedText || !addedLine) continue
const similarity = calculateSimilarity(removedText, addedLine.code) if (similarity < 0.5) { // Too different — just use syntax highlighting const tokens = beforeTokens[i] result.push({ code: tokens ? <HighlightedTokens tokens={tokens} /> : <text style={{ color: CODE_FG, whiteSpace: "nowrap" }}>{removedText}</text>, type, oldLineNumber, newLineNumber, pairedWith: pair.add, }) } else { const parts = diffWords(removedText, addedLine.code) result.push({ code: <WordDiffTokens parts={parts} mode="remove" />, type, oldLineNumber, newLineNumber, pairedWith: pair.add, }) } oldLineNumber++ } else if (pair && pair.add === i && pair.remove !== undefined) { // Added line paired with a removal — try word diff const removedLine = processedLines[pair.remove] const addedLine = processedLines[i] if (!removedLine || !addedLine) continue
const similarity = calculateSimilarity(removedLine.code, addedLine.code) if (similarity < 0.5) { const tokens = afterTokens[i] result.push({ code: tokens ? <HighlightedTokens tokens={tokens} /> : <text style={{ color: CODE_FG, whiteSpace: "nowrap" }}>{addedLine.code}</text>, type, oldLineNumber, newLineNumber, pairedWith: pair.remove, }) } else { const parts = diffWords(removedLine.code, addedLine.code) result.push({ code: <WordDiffTokens parts={parts} mode="add" />, type, oldLineNumber, newLineNumber, pairedWith: pair.remove, }) } newLineNumber++ } else { // Unpaired line — just syntax highlight const tokens = type === "remove" ? beforeTokens[i] : type === "add" ? afterTokens[i] : beforeTokens[i] || afterTokens[i]
result.push({ code: tokens && tokens.length > 0 ? ( <HighlightedTokens tokens={tokens} /> ) : ( <text style={{ color: CODE_FG, whiteSpace: "nowrap" }}>{code}</text> ), type, oldLineNumber, newLineNumber, })
if (type === "remove") { oldLineNumber++ } else if (type === "add") { newLineNumber++ } else { oldLineNumber++ newLineNumber++ } } }
return result.map(({ type, code, oldLineNumber, newLineNumber, pairedWith }, index) => ({ oldLineNumber: oldLineNumber.toString(), newLineNumber: newLineNumber.toString(), code, type, pairedWith, key: `line-${index}`, }))}
// ── Unified view ─────────────────────────────────────────────────────
function UnifiedView({ diff, maxWidth,}: { diff: DiffLineData[] maxWidth: number}) { return ( <> {diff.map(({ code, type, key, newLineNumber }) => { const lineNumber = newLineNumber && newLineNumber !== "0" ? newLineNumber.padStart(maxWidth) : " ".repeat(maxWidth)
const codeBg = type === "add" ? ADDED_BG : type === "remove" ? REMOVED_BG : UNCHANGED_CODE_BG
return ( <div key={key} style={{ display: "flex", flexDirection: "row" }}> <LineNumberGutter lineNumber={lineNumber} type={type} maxWidth={maxWidth} /> <div style={{ flexGrow: 1, paddingLeft: 4, backgroundColor: codeBg, }} > {code} </div> </div> ) })} </> )}
// ── Split view ───────────────────────────────────────────────────────
interface SplitLine { left: DiffLineData & { lineNumber: string } right: DiffLineData & { lineNumber: string }}
function buildSplitLines( diff: DiffLineData[], leftMaxWidth: number, rightMaxWidth: number,): SplitLine[] { const splitLines: SplitLine[] = [] const processedIndices = new Set<number>()
for (let i = 0; i < diff.length; i++) { if (processedIndices.has(i)) continue const line = diff[i] if (!line) continue
if (line.type === "remove" && line.pairedWith !== undefined) { const pairedLine = diff[line.pairedWith] if (pairedLine) { splitLines.push({ left: { ...line, lineNumber: line.oldLineNumber.padStart(leftMaxWidth) }, right: { ...pairedLine, lineNumber: pairedLine.newLineNumber.padStart(rightMaxWidth) }, }) processedIndices.add(i) processedIndices.add(line.pairedWith) } } else if (line.type === "add" && line.pairedWith !== undefined) { continue } else if (line.type === "remove") { splitLines.push({ left: { ...line, lineNumber: line.oldLineNumber.padStart(leftMaxWidth) }, right: { lineNumber: " ".repeat(rightMaxWidth), code: <text>{""}</text>, type: "empty", oldLineNumber: "", newLineNumber: "", key: `${line.key}-empty-right`, }, }) processedIndices.add(i) } else if (line.type === "add") { splitLines.push({ left: { lineNumber: " ".repeat(leftMaxWidth), code: <text>{""}</text>, type: "empty", oldLineNumber: "", newLineNumber: "", key: `${line.key}-empty-left`, }, right: { ...line, lineNumber: line.newLineNumber.padStart(rightMaxWidth) }, }) processedIndices.add(i) } else { splitLines.push({ left: { ...line, lineNumber: line.oldLineNumber.padStart(leftMaxWidth) }, right: { ...line, lineNumber: line.newLineNumber.padStart(rightMaxWidth) }, }) processedIndices.add(i) } }
return splitLines}
function SplitSideGutter({ lineNumber, type, maxWidth,}: { lineNumber: string type: string maxWidth: number}) { const bg = type === "remove" ? REMOVED_LINE_NUMBER_BG : type === "add" ? ADDED_LINE_NUMBER_BG : LINE_NUMBER_BG const fg = type === "remove" || type === "add" ? LINE_NUMBER_FG_BRIGHT : LINE_NUMBER_FG_DIM
return ( <div style={{ flexShrink: 0, alignSelf: "stretch", backgroundColor: bg, }} > <text style={{ color: fg, whiteSpace: "nowrap", fontSize: 13 }}> {` ${lineNumber} `} </text> </div> )}
function SplitSideCode({ code, type,}: { code: React.ReactNode type: string}) { const bg = type === "remove" ? REMOVED_BG : type === "add" ? ADDED_BG : UNCHANGED_CODE_BG
return ( <div style={{ flexGrow: 1, paddingLeft: 4, minWidth: 0, backgroundColor: bg, }} > {code} </div> )}
function SplitView({ diff, leftMaxWidth, rightMaxWidth,}: { diff: DiffLineData[] leftMaxWidth: number rightMaxWidth: number}) { const splitLines = buildSplitLines(diff, leftMaxWidth, rightMaxWidth)
return ( <> {splitLines.map(({ left, right }) => ( <div key={left.key} style={{ display: "flex", flexDirection: "row" }}> {/* Left side (removals / old) */} <div style={{ display: "flex", flexDirection: "row", width: "50%" }}> <SplitSideGutter lineNumber={left.lineNumber} type={left.type} maxWidth={leftMaxWidth} /> <SplitSideCode code={left.code} type={left.type} /> </div>
{/* Right side (additions / new) */} <div style={{ display: "flex", flexDirection: "row", width: "50%" }}> <SplitSideGutter lineNumber={right.lineNumber} type={right.type} maxWidth={rightMaxWidth} /> <SplitSideCode code={right.code} type={right.type} /> </div> </div> ))} </> )}
// ── DiffViewer (main export) ─────────────────────────────────────────
export interface DiffViewerProps { hunks: Hunk[] filePath?: string splitView?: boolean}
export function DiffViewer({ hunks, filePath = "", splitView = false,}: DiffViewerProps) { if (hunks.length === 0) { return ( <div style={{ padding: 16 }}> <text style={{ color: LINE_NUMBER_FG_DIM }}>No changes</text> </div> ) }
// Calculate max line number widths across all hunks const allLines = hunks.flatMap((h) => h.lines) let oldLineNum = hunks[0]?.oldStart || 1 let newLineNum = hunks[0]?.newStart || 1
// Count to find max old line let tempOld = oldLineNum let tempNew = newLineNum for (const line of allLines) { if (line.startsWith("-")) tempOld++ else if (line.startsWith("+")) tempNew++ else { tempOld++ tempNew++ } } const leftMaxWidth = Math.max(tempOld.toString().length, 2) const rightMaxWidth = Math.max(tempNew.toString().length, 2) const maxWidth = Math.max(leftMaxWidth, rightMaxWidth)
return ( <div style={{ display: "flex", flexDirection: "column", fontFamily: "Menlo", fontSize: 13, }} > {hunks.map((hunk, hunkIdx) => { const diff = processHunkLines(hunk.lines, hunk.oldStart, filePath)
return ( <React.Fragment key={hunk.newStart}> {splitView ? ( <SplitView diff={diff} leftMaxWidth={leftMaxWidth} rightMaxWidth={rightMaxWidth} /> ) : ( <UnifiedView diff={diff} maxWidth={maxWidth} /> )} {hunkIdx < hunks.length - 1 && ( <div style={{ paddingLeft: 4 }}> <text style={{ color: SEPARATOR_FG, whiteSpace: "nowrap", fontSize: 13, }} > {`${" ".repeat(maxWidth + 2)}...`} </text> </div> )} </React.Fragment> ) })} </div> )}
// ── Example app ──────────────────────────────────────────────────────
// Hardcoded example patch — a realistic TypeScript file changeconst exampleHunks: Hunk[] = [ { oldStart: 1, oldLines: 8, newStart: 1, newLines: 10, lines: [ " import React from 'react'", " import { useState } from 'react'", " ", "-function Counter({ initial }: { initial: number }) {", "- const [count, setCount] = useState(initial)", "+interface CounterProps {", "+ initial: number", "+ step?: number", "+}", "+", "+function Counter({ initial, step = 1 }: CounterProps) {", "+ const [count, setCount] = useState(initial)", " ", " return (", " <div>", ], }, { oldStart: 12, oldLines: 5, newStart: 14, newLines: 7, lines: [ " <span>{count}</span>", "- <button onClick={() => setCount(c => c + 1)}>+</button>", "- <button onClick={() => setCount(c => c - 1)}>-</button>", "+ <button onClick={() => setCount(c => c + step)}>", "+ Increment by {step}", "+ </button>", "+ <button onClick={() => setCount(c => c - step)}>", "+ Decrement by {step}", "+ </button>", " </div>", " )", ], },]
function App() { return ( <div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#11111b", }} > {/* Title bar */} <div style={{ padding: 12, paddingLeft: 16, backgroundColor: "#1e1e2e", }} > <text style={{ color: "#cdd6f4", fontSize: 14, fontWeight: "bold" }}> counter.tsx </text> </div>
{/* Scrollable diff — bg matches unchanged code so empty area below content doesn't look different (minHeight: 100% doesn't work inside scroll containers in GPUI) */} <div style={{ flexGrow: 1, overflow: "scroll", backgroundColor: UNCHANGED_CODE_BG, }} > <DiffViewer hunks={exampleHunks} filePath="counter.tsx" splitView={false} /> </div> </div> )}
// ── Main ─────────────────────────────────────────────────────────────// Only runs when executed directly (not when imported by tests).
const isEntryPoint = typeof Bun !== "undefined" ? Bun.main === import.meta.path : process.argv[1]?.endsWith("diff.tsx") || process.argv[1]?.endsWith("diff.js")
if (isEntryPoint) { render(<App />, { title: "GPUIX Diff Viewer", width: 900, height: 600, // Agent checks need real GPU paint, not control of the user's keyboard. focus: process.env.GPUIX_BACKGROUND !== "1", })}