Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents as a team: lifecycle, merge queue, billing and a new shell | 1 | import { type HighlighterCore, createHighlighterCore } from "shiki/core"; |
| 2 | import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; | |
| 3 | ||
| 4 | import type { FileDiff } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import type { HighlightedFile, HighlightedLine } from "./diff"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Syntax highlighting, shared by the server and the browser. Grammars are | |
| 10 | * imported on first use, so a page that highlights nothing loads none. | |
| 11 | */ | |
| 12 | ||
| 13 | /** File extension (or whole file name) to Shiki language. */ | |
| 14 | const LANGUAGES: Record<string, string> = { | |
| 15 | rs: "rust", | |
| 16 | ts: "typescript", | |
| 17 | tsx: "tsx", | |
| 18 | js: "javascript", | |
| 19 | mjs: "javascript", | |
| 20 | jsx: "jsx", | |
| 21 | json: "json", | |
| 22 | jsonc: "jsonc", | |
| 23 | toml: "toml", | |
| 24 | yaml: "yaml", | |
| 25 | yml: "yaml", | |
| 26 | md: "markdown", | |
| 27 | css: "css", | |
| 28 | html: "html", | |
| 29 | sql: "sql", | |
| 30 | sh: "shellscript", | |
| 31 | bash: "shellscript", | |
| 32 | py: "python", | |
| 33 | go: "go", | |
| 34 | dockerfile: "docker", | |
| 35 | }; | |
| 36 | ||
| 37 | export const THEME = "vitesse-dark"; | |
| 38 | ||
| 39 | /** The language of a file, from its name, if it is one g1t highlights. */ | |
| 40 | export function languageOf(path: string): string | null { | |
| 41 | const name = path.split("/").pop()!.toLowerCase(); | |
| 42 | return LANGUAGES[name.includes(".") ? name.split(".").pop()! : name] ?? null; | |
| 43 | } | |
| 44 | ||
| 45 | /** The language a fenced block names (`rust`, `ts`, `sh`…), if g1t highlights it. */ | |
| 46 | export function languageNamed(name: string): string | null { | |
| 47 | const wanted = name.toLowerCase(); | |
| 48 | if (Object.values(LANGUAGES).includes(wanted)) return wanted; | |
| 49 | return LANGUAGES[wanted] ?? (wanted === "shell" || wanted === "console" ? "shellscript" : null); | |
| 50 | } | |
| 51 | ||
| 52 | let highlighter: Promise<HighlighterCore> | undefined; | |
| 53 | ||
| 54 | export function getHighlighter(): Promise<HighlighterCore> { | |
| 55 | highlighter ??= createHighlighterCore({ | |
| 56 | themes: [import("shiki/themes/vitesse-dark.mjs")], | |
| 57 | langs: [ | |
| 58 | import("shiki/langs/rust.mjs"), | |
| 59 | import("shiki/langs/typescript.mjs"), | |
| 60 | import("shiki/langs/tsx.mjs"), | |
| 61 | import("shiki/langs/javascript.mjs"), | |
| 62 | import("shiki/langs/jsx.mjs"), | |
| 63 | import("shiki/langs/json.mjs"), | |
| 64 | import("shiki/langs/jsonc.mjs"), | |
| 65 | import("shiki/langs/toml.mjs"), | |
| 66 | import("shiki/langs/yaml.mjs"), | |
| 67 | import("shiki/langs/markdown.mjs"), | |
| 68 | import("shiki/langs/css.mjs"), | |
| 69 | import("shiki/langs/html.mjs"), | |
| 70 | import("shiki/langs/sql.mjs"), | |
| 71 | import("shiki/langs/shellscript.mjs"), | |
| 72 | import("shiki/langs/python.mjs"), | |
| 73 | import("shiki/langs/go.mjs"), | |
| 74 | import("shiki/langs/docker.mjs"), | |
| 75 | ], | |
| 76 | engine: createJavaScriptRegexEngine(), | |
| 77 | }); | |
| 78 | return highlighter; | |
| 79 | } | |
| 80 | ||
| 81 | function escape(text: string): string { | |
| 82 | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 83 | } | |
| 84 | ||
| 85 | /** Each line of `text` as HTML, coloured. */ | |
| 86 | export function linesToHtml(core: HighlighterCore, text: string, lang: string): string[] { | |
| 87 | const { tokens } = core.codeToTokens(text, { lang, theme: THEME }); | |
| 88 | return tokens.map((row) => | |
| 89 | row | |
| 90 | .map((token) => | |
| 91 | token.color ? `<span style="color:${token.color}">${escape(token.content)}</span>` : escape(token.content), | |
| 92 | ) | |
| 93 | .join(""), | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | /** Larger files in a diff are left plain. */ | |
| 98 | const MAX_FILE_DIFF_CHARS = 60_000; | |
| 99 | ||
| 100 | /** | |
| 101 | * A file's diff with each line's text highlighted, or null when its | |
| 102 | * language is unknown or it is too large. Each hunk is highlighted twice, | |
| 103 | * as it was and as it is, so a line's colours come from the side it is on. | |
| 104 | */ | |
| 105 | export async function highlightFile(file: FileDiff): Promise<HighlightedFile | null> { | |
| 106 | const lang = languageOf(file.path); | |
| 107 | if (!lang || file.binary) return null; | |
| 108 | const size = file.hunks.reduce((sum, hunk) => sum + hunk.lines.reduce((n, line) => n + line.text.length, 0), 0); | |
| 109 | if (size > MAX_FILE_DIFF_CHARS) return null; | |
| 110 | const core = await getHighlighter(); | |
| 111 | try { | |
| 112 | const hunks = file.hunks.map((hunk) => { | |
| 113 | const lines: HighlightedLine[] = hunk.lines.map((line) => ({ ...line })); | |
| 114 | for (const side of ["old", "new"] as const) { | |
| 115 | const indexes = lines | |
| 116 | .map((line, index) => (line.kind === (side === "old" ? "add" : "delete") ? -1 : index)) | |
| 117 | .filter((index) => index >= 0); | |
| 118 | const html = linesToHtml(core, indexes.map((index) => lines[index]!.text).join("\n"), lang); | |
| 119 | html.forEach((row, n) => { | |
| 120 | const line = lines[indexes[n]!]; | |
| 121 | // Context lines are the same on both sides; the new side wins. | |
| 122 | if (!line || (side === "old" && line.kind === "context")) return; | |
| 123 | line.html = row; | |
| 124 | }); | |
| 125 | } | |
| 126 | return { lines }; | |
| 127 | }); | |
| 128 | return { ...file, hunks }; | |
| 129 | } catch { | |
| 130 | return null; | |
| 131 | } | |
| 132 | } |