@young1lin/dsh-ui-gitworkbench 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Lane assignment for the commit graph — the branch/merge diagram down the left
3
+ * edge of the history list, the way IDEA, gitk and GitHub's network view draw it.
4
+ *
5
+ * A LANE is a vertical line. Each one is "waiting for" a commit hash: the hash
6
+ * of the commit that will terminate it further down the list. Walking the log
7
+ * newest-first, every commit takes over the lane(s) waiting for it, then hands
8
+ * that lane to its FIRST parent and opens a lane for each additional one. That
9
+ * single rule is what makes mainline history read as one straight line and the
10
+ * merged branch the one that curves away — first-parent order is the meaning of
11
+ * `%p`, not an arbitrary choice.
12
+ *
13
+ * Everything here is pure. The renderer that consumes it draws paths and has no
14
+ * decisions of its own, so the graph's whole behaviour is testable without a DOM.
15
+ *
16
+ * The list is PAGED, so the layout is always over a prefix of history. A lane
17
+ * still waiting for an unfetched parent simply leaves the bottom edge, which is
18
+ * exactly what should be drawn: the line continues because the history does.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ /** The only thing the layout needs from a commit. */
24
+ export interface GraphInput {
25
+ readonly hash: string
26
+ /** Parent hashes in git's order — first parent first. */
27
+ readonly parents: readonly string[]
28
+ }
29
+
30
+ /** One row's geometry, in lane indices. The renderer turns these into paths. */
31
+ export interface GraphRow {
32
+ readonly hash: string
33
+ /** Lane the commit's dot sits in. */
34
+ readonly lane: number
35
+ /**
36
+ * Lanes arriving from the rows above and ending at this commit. Contains
37
+ * {@link lane} when this commit continues the line it sits on, and additional
38
+ * entries when branches converge here. Empty for a tip.
39
+ */
40
+ readonly into: readonly number[]
41
+ /**
42
+ * Lanes leaving toward the rows below. Contains {@link lane} unless this is a
43
+ * root commit, plus one entry per additional parent.
44
+ */
45
+ readonly outOf: readonly number[]
46
+ /** Lanes crossing this row untouched — drawn as an unbroken vertical line. */
47
+ readonly through: readonly number[]
48
+ /** Lanes in use at this row; every index above is `< width`. */
49
+ readonly width: number
50
+ readonly isMerge: boolean
51
+ }
52
+
53
+ export interface Graph {
54
+ readonly rows: readonly GraphRow[]
55
+ /** The widest row — how much horizontal space the column reserves. */
56
+ readonly width: number
57
+ }
58
+
59
+ /**
60
+ * Assign lanes to a page of commits.
61
+ * @param commits - newest-first, as `git log` orders them.
62
+ * @returns per-row geometry plus the column width to reserve.
63
+ */
64
+ export function layoutGraph(commits: readonly GraphInput[]): Graph {
65
+ /** `lanes[i]` is the hash lane `i` is waiting for; null means the lane is free. */
66
+ const lanes: (string | null)[] = []
67
+ const rows: GraphRow[] = []
68
+ let width = 0
69
+
70
+ const freeSlot = (): number => {
71
+ const reused = lanes.indexOf(null)
72
+ if (reused !== -1) return reused
73
+ lanes.push(null)
74
+ return lanes.length - 1
75
+ }
76
+ const occupiedWidth = (state: readonly (string | null)[]): number => {
77
+ for (let i = state.length - 1; i >= 0; i -= 1) if (state[i] !== null) return i + 1
78
+ return 0
79
+ }
80
+
81
+ for (const commit of commits) {
82
+ const before = [...lanes]
83
+
84
+ // Every lane waiting for this commit ends here. The leftmost becomes the
85
+ // commit's own lane; picking the leftmost is what keeps the graph compact
86
+ // and holds the mainline against the left edge.
87
+ const into: number[] = []
88
+ for (let i = 0; i < lanes.length; i += 1) if (lanes[i] === commit.hash) into.push(i)
89
+
90
+ const lane = into.length > 0 ? into[0]! : freeSlot()
91
+ for (const other of into) if (other !== lane) lanes[other] = null
92
+
93
+ // Hand the lane to the first parent; open one per additional parent. A
94
+ // parent some other lane is already waiting for reuses that lane instead of
95
+ // opening a second line to the same commit.
96
+ const outOf: number[] = []
97
+ const seen = new Set<string>()
98
+ lanes[lane] = null
99
+ for (const parent of commit.parents) {
100
+ if (seen.has(parent)) continue
101
+ seen.add(parent)
102
+ if (outOf.length === 0) {
103
+ lanes[lane] = parent
104
+ outOf.push(lane)
105
+ continue
106
+ }
107
+ let target = lanes.indexOf(parent)
108
+ if (target === -1) {
109
+ target = freeSlot()
110
+ lanes[target] = parent
111
+ }
112
+ outOf.push(target)
113
+ }
114
+
115
+ // A lane passes through when it was busy before, is still busy after, and
116
+ // its expectation did not change — i.e. this commit had nothing to do with it.
117
+ const through: number[] = []
118
+ for (let i = 0; i < before.length; i += 1) {
119
+ if (i === lane) continue
120
+ if (before[i] !== null && before[i] === lanes[i]) through.push(i)
121
+ }
122
+
123
+ // Trailing free lanes cost width without carrying a line.
124
+ while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop()
125
+
126
+ const rowWidth = Math.max(occupiedWidth(before), occupiedWidth(lanes), lane + 1)
127
+ width = Math.max(width, rowWidth)
128
+ rows.push({
129
+ hash: commit.hash,
130
+ lane,
131
+ into,
132
+ outOf,
133
+ through,
134
+ width: rowWidth,
135
+ isMerge: seen.size > 1,
136
+ })
137
+ }
138
+
139
+ return { rows, width }
140
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Pure diff-model: unified-diff rows and word-level ranges. Kept free of
3
+ * React/CSS so `tests/diff-regression.test.ts` can load it without pulling the
4
+ * panel (CSS modules + the client shim) into vitest.
5
+ *
6
+ * Colour is not this module's job — `highlight.ts` runs Shiki against a real
7
+ * TextMate theme. What stays here is the part Shiki does not do: reading the
8
+ * diff itself, and working out which words on a line actually changed.
9
+ */
10
+
11
+ export interface Row {
12
+ readonly kind: 'add' | 'del' | 'context' | 'hunk'
13
+ readonly text: string
14
+ readonly oldL: number
15
+ readonly newL: number
16
+ }
17
+
18
+ export interface RowWithRanges extends Row {
19
+ ranges?: Array<readonly [number, number]>
20
+ }
21
+
22
+ /**
23
+ * Parse a unified diff segment into typed rows, tracking line numbers.
24
+ * @param segment - one file's `diff --git` text (headers are skipped).
25
+ */
26
+ export function parseRows(segment: string): Row[] {
27
+ const rows: Row[] = []
28
+ const lines = segment.split('\n')
29
+ let oldL = 0
30
+ let newL = 0
31
+ for (const line of lines) {
32
+ if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('old mode')
33
+ || line.startsWith('new mode') || line.startsWith('--- ') || line.startsWith('+++ ')
34
+ || line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity')
35
+ || line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('\\ No newline')) continue
36
+ if (line.startsWith('@@')) {
37
+ const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
38
+ if (m) { oldL = Number.parseInt(m[1]!, 10); newL = Number.parseInt(m[2]!, 10) }
39
+ rows.push({ kind: 'hunk', text: line, oldL: 0, newL: 0 })
40
+ continue
41
+ }
42
+ if (line.startsWith('+')) {
43
+ rows.push({ kind: 'add', text: line.slice(1), oldL: 0, newL })
44
+ newL += 1
45
+ } else if (line.startsWith('-')) {
46
+ rows.push({ kind: 'del', text: line.slice(1), oldL, newL: 0 })
47
+ oldL += 1
48
+ } else {
49
+ rows.push({ kind: 'context', text: line.slice(1), oldL, newL })
50
+ oldL += 1; newL += 1
51
+ }
52
+ }
53
+ return rows
54
+ }
55
+
56
+ /**
57
+ * Which line-number columns a unified diff actually uses.
58
+ * A new file is only `+` rows (`@@ -0,0 +1,n`), a deletion is only `-`;
59
+ * those should not keep an empty second gutter. Context or a mix needs both.
60
+ * @param rows - parsed unified-diff rows.
61
+ */
62
+ export function gutterSides(rows: readonly Row[]): { old: boolean; new: boolean } {
63
+ let old = false
64
+ let neu = false
65
+ for (const row of rows) {
66
+ if (row.kind === 'del') old = true
67
+ else if (row.kind === 'add') neu = true
68
+ else if (row.kind === 'context') { old = true; neu = true }
69
+ if (old && neu) return { old: true, new: true }
70
+ }
71
+ return { old, new: neu }
72
+ }
73
+
74
+ /**
75
+ * Pair adjacent +/- runs index-wise and compute word-level changed ranges per line.
76
+ * @param rows - parsed unified-diff rows.
77
+ */
78
+ export function attachWordRanges(rows: Row[]): RowWithRanges[] {
79
+ const out: RowWithRanges[] = rows.map(row => ({ ...row }))
80
+ let i = 0
81
+ while (i < out.length) {
82
+ if (out[i]!.kind !== 'del' && out[i]!.kind !== 'add') { i += 1; continue }
83
+ let delEnd = i
84
+ while (delEnd < out.length && out[delEnd]!.kind === 'del') delEnd += 1
85
+ let addEnd = delEnd
86
+ while (addEnd < out.length && out[addEnd]!.kind === 'add') addEnd += 1
87
+ const pairs = Math.min(delEnd - i, addEnd - delEnd)
88
+ for (let p = 0; p < pairs; p++) {
89
+ const delRow = out[i + p]!
90
+ const addRow = out[delEnd + p]!
91
+ const { oldRanges, newRanges } = changedRanges(delRow.text, addRow.text)
92
+ delRow.ranges = oldRanges
93
+ addRow.ranges = newRanges
94
+ }
95
+ i = addEnd
96
+ }
97
+ return out
98
+ }
99
+
100
+ interface Tok { readonly text: string; readonly start: number; readonly isWs: boolean }
101
+
102
+ function tokensWithOffsets(text: string): Tok[] {
103
+ const out: Tok[] = []
104
+ const re = /\s+|\S+/g
105
+ let m: RegExpExecArray | null
106
+ while ((m = re.exec(text)) !== null) out.push({ text: m[0], start: m.index, isWs: m[0].trim().length === 0 })
107
+ return out
108
+ }
109
+
110
+ /** Word-level diff of two text lines via LCS over whitespace-preserved tokens.
111
+ * Falls back to whole-line emphasis when the pair is too large. */
112
+ function changedRanges(oldText: string, newText: string): { oldRanges: Array<readonly [number, number]>; newRanges: Array<readonly [number, number]> } {
113
+ if (oldText === newText) return { oldRanges: [], newRanges: [] }
114
+ const a = tokensWithOffsets(oldText)
115
+ const b = tokensWithOffsets(newText)
116
+ if (a.length * b.length > 200_000 || a.length + b.length < 2) {
117
+ return { oldRanges: a.length > 0 ? [[0, oldText.length]] : [], newRanges: b.length > 0 ? [[0, newText.length]] : [] }
118
+ }
119
+ const n = a.length
120
+ const m = b.length
121
+ const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
122
+ for (let i = n - 1; i >= 0; i -= 1) {
123
+ for (let j = m - 1; j >= 0; j -= 1) {
124
+ const same = (a[i]!.isWs && b[j]!.isWs) || a[i]!.text === b[j]!.text
125
+ dp[i]![j] = same ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
126
+ }
127
+ }
128
+ const oldRanges: Array<readonly [number, number]> = []
129
+ const newRanges: Array<readonly [number, number]> = []
130
+ const push = (into: Array<readonly [number, number]>, start: number, end: number): void => {
131
+ const last = into[into.length - 1]
132
+ if (last !== undefined && last[1] === start) into[into.length - 1] = [last[0], end]
133
+ else into.push([start, end])
134
+ }
135
+ let i = 0
136
+ let j = 0
137
+ while (i < n && j < m) {
138
+ const same = (a[i]!.isWs && b[j]!.isWs) || a[i]!.text === b[j]!.text
139
+ if (same) { i += 1; j += 1; continue }
140
+ if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
141
+ push(oldRanges, a[i]!.start, a[i]!.start + a[i]!.text.length)
142
+ i += 1
143
+ } else {
144
+ push(newRanges, b[j]!.start, b[j]!.start + b[j]!.text.length)
145
+ j += 1
146
+ }
147
+ }
148
+ while (i < n) { push(oldRanges, a[i]!.start, a[i]!.start + a[i]!.text.length); i += 1 }
149
+ while (j < m) { push(newRanges, b[j]!.start, b[j]!.start + b[j]!.text.length); j += 1 }
150
+ return { oldRanges, newRanges }
151
+ }
152
+
153
+ export interface PaintedTok {
154
+ readonly text: string
155
+ readonly color?: string
156
+ readonly italic?: boolean
157
+ readonly mark: boolean
158
+ }
159
+
160
+ /**
161
+ * Overlay word-level change ranges onto already-lexed tokens, instead of
162
+ * re-lexing slices (a slice of a comment looks like code).
163
+ * @param tokens - one line's tokens (`text` plus optional `color` from Shiki).
164
+ * @param ranges - changed character ranges on that line.
165
+ */
166
+ export function overlayRanges(
167
+ tokens: ReadonlyArray<{ readonly text: string; readonly color?: string; readonly italic?: boolean }>,
168
+ ranges: ReadonlyArray<readonly [number, number]>,
169
+ ): PaintedTok[] {
170
+ if (tokens.length === 0) return []
171
+ if (ranges.length === 0) return tokens.map(tok => ({ text: tok.text, color: tok.color, italic: tok.italic, mark: false }))
172
+ const out: PaintedTok[] = []
173
+ let offset = 0
174
+ let ri = 0
175
+ for (const tok of tokens) {
176
+ let local = 0
177
+ while (local < tok.text.length) {
178
+ const abs = offset + local
179
+ while (ri < ranges.length && ranges[ri]![1] <= abs) ri += 1
180
+ const range = ranges[ri]
181
+ const marked = range !== undefined && abs >= range[0] && abs < range[1]
182
+ const cut = marked
183
+ ? Math.min(tok.text.length, range[1] - offset)
184
+ : range !== undefined && range[0] > abs
185
+ ? Math.min(tok.text.length, range[0] - offset)
186
+ : tok.text.length
187
+ if (cut > local) out.push({ text: tok.text.slice(local, cut), color: tok.color, italic: tok.italic, mark: marked })
188
+ local = cut > local ? cut : local + 1
189
+ }
190
+ offset += tok.text.length
191
+ }
192
+ return out
193
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Diff highlighter: Shiki core with the JavaScript regex engine (no Oniguruma
3
+ * WASM) and a real TextMate theme per drawer palette. The css-variables theme
4
+ * only has ~8 token slots, so identifiers, types and punctuation all collapsed
5
+ * to the body colour — which is why a homemade four-kind pass looked the same
6
+ * as "only keywords are coloured". Bundled themes emit a hex per scope.
7
+ *
8
+ * Boot grammars: TypeScript, shell, JSON. Everything else the drawer opens
9
+ * loads lazily.
10
+ */
11
+ import { createHighlighterCoreSync } from 'shiki/core'
12
+ import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from 'shiki/engine/javascript'
13
+ import langTs from '@shikijs/langs/typescript'
14
+ import langBash from '@shikijs/langs/shellscript'
15
+ import langJson from '@shikijs/langs/json'
16
+ import githubDark from 'shiki/themes/github-dark-default.mjs'
17
+ import githubLight from 'shiki/themes/github-light-default.mjs'
18
+ import darkPlus from 'shiki/themes/dark-plus.mjs'
19
+ import lightPlus from 'shiki/themes/light-plus.mjs'
20
+ import oneDarkPro from 'shiki/themes/one-dark-pro.mjs'
21
+ import oneLight from 'shiki/themes/one-light.mjs'
22
+ import solarizedDark from 'shiki/themes/solarized-dark.mjs'
23
+ import solarizedLight from 'shiki/themes/solarized-light.mjs'
24
+ import nord from 'shiki/themes/nord.mjs'
25
+ import synthwave84 from 'shiki/themes/synthwave-84.mjs'
26
+ import type { HighlighterCore } from 'shiki/core'
27
+ import type { Row } from './diff-model.ts'
28
+
29
+ type LangModule = { default: typeof langTs }
30
+
31
+ const LANGS = [langTs, langBash, langJson]
32
+
33
+ const THEMES = [
34
+ githubDark, githubLight, darkPlus, lightPlus,
35
+ oneDarkPro, oneLight, solarizedDark, solarizedLight,
36
+ nord, synthwave84,
37
+ ]
38
+
39
+ const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
40
+ ['python', () => import('@shikijs/langs/python')],
41
+ ['css', () => import('@shikijs/langs/css')],
42
+ ['markdown', () => import('@shikijs/langs/markdown')],
43
+ ['html', () => import('@shikijs/langs/html')],
44
+ ['yaml', () => import('@shikijs/langs/yaml')],
45
+ ['toml', () => import('@shikijs/langs/toml')],
46
+ ['rust', () => import('@shikijs/langs/rust')],
47
+ ['go', () => import('@shikijs/langs/go')],
48
+ ['java', () => import('@shikijs/langs/java')],
49
+ ['c', () => import('@shikijs/langs/c')],
50
+ ['cpp', () => import('@shikijs/langs/cpp')],
51
+ ])
52
+
53
+ const LANG_ALIASES = new Map<string, string>([
54
+ ['typescript', 'typescript'], ['ts', 'typescript'], ['tsx', 'typescript'],
55
+ ['javascript', 'typescript'], ['js', 'typescript'], ['jsx', 'typescript'],
56
+ ['mjs', 'typescript'], ['cjs', 'typescript'],
57
+ ['shellscript', 'shellscript'], ['bash', 'shellscript'], ['sh', 'shellscript'],
58
+ ['shell', 'shellscript'], ['zsh', 'shellscript'], ['ps1', 'shellscript'],
59
+ ['json', 'json'], ['jsonc', 'json'],
60
+ ['py', 'python'], ['python', 'python'],
61
+ ['css', 'css'], ['scss', 'css'], ['less', 'css'],
62
+ ['md', 'markdown'], ['markdown', 'markdown'],
63
+ ['html', 'html'], ['htm', 'html'],
64
+ ['yaml', 'yaml'], ['yml', 'yaml'],
65
+ ['toml', 'toml'],
66
+ ['rs', 'rust'], ['rust', 'rust'],
67
+ ['go', 'go'],
68
+ ['java', 'java'],
69
+ ['c', 'c'],
70
+ ['cpp', 'cpp'], ['h', 'c'], ['hpp', 'cpp'],
71
+ ])
72
+
73
+ /** Drawer `data-gs-theme` → a loaded Shiki theme name. */
74
+ const PALETTE_THEMES: Record<string, string> = {
75
+ 'github-dark': 'github-dark-default',
76
+ 'github-light': 'github-light-default',
77
+ 'vscode-dark': 'dark-plus',
78
+ 'vscode-light': 'light-plus',
79
+ 'one-dark': 'one-dark-pro',
80
+ 'one-light': 'one-light',
81
+ 'solarized-dark': 'solarized-dark',
82
+ 'solarized-light': 'solarized-light',
83
+ 'nord-dark': 'nord',
84
+ 'nord-light': 'github-light-default',
85
+ 'cyberpunk-dark': 'synthwave-84',
86
+ 'cyberpunk-light': 'synthwave-84',
87
+ }
88
+
89
+ const regexEngine = createJavaScriptRegexEngine({
90
+ forgiving: true,
91
+ regexConstructor: pattern => defaultJavaScriptRegexConstructor(pattern, {
92
+ lazyCompileLength: Number.POSITIVE_INFINITY,
93
+ }),
94
+ })
95
+
96
+ let singleton: HighlighterCore | undefined
97
+
98
+ function highlighter(): HighlighterCore {
99
+ singleton ??= createHighlighterCoreSync({
100
+ themes: THEMES,
101
+ langs: LANGS,
102
+ engine: regexEngine,
103
+ })
104
+ return singleton
105
+ }
106
+
107
+ const requested = new Set<string>()
108
+ const listeners = new Set<() => void>()
109
+ let loadCount = 0
110
+
111
+ /** Subscribe to lazy-grammar loads so a first render can re-highlight. */
112
+ export function subscribeGrammarLoaded(listener: () => void): () => void {
113
+ listeners.add(listener)
114
+ return () => { listeners.delete(listener) }
115
+ }
116
+
117
+ /** Snapshot for `useSyncExternalStore`. */
118
+ export function grammarLoadCount(): number {
119
+ return loadCount
120
+ }
121
+
122
+ function ensureGrammar(resolved: string): boolean {
123
+ const load = LAZY_GRAMMARS.get(resolved)
124
+ if (load === undefined) return true
125
+ if (highlighter().getLoadedLanguages().includes(resolved)) return true
126
+ if (!requested.has(resolved)) {
127
+ requested.add(resolved)
128
+ void load().then(mod => {
129
+ highlighter().loadLanguageSync(mod.default)
130
+ loadCount += 1
131
+ for (const listener of listeners) listener()
132
+ })
133
+ }
134
+ return false
135
+ }
136
+
137
+ export interface HighlightRun {
138
+ readonly text: string
139
+ readonly color: string | undefined
140
+ readonly italic?: boolean
141
+ }
142
+
143
+ /**
144
+ * Language id Shiki accepts for this path, or undefined for plain text.
145
+ * @param path - file path from the unified diff.
146
+ */
147
+ export function shikiLangOf(path: string): string | undefined {
148
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
149
+ return LANG_ALIASES.get(ext)
150
+ }
151
+
152
+ /**
153
+ * Shiki theme loaded for this drawer palette.
154
+ * @param palette - `data-gs-theme` value (`github-dark`, …).
155
+ */
156
+ export function shikiThemeOf(palette: string): string {
157
+ return PALETTE_THEMES[palette] ?? (palette.endsWith('light') ? 'github-light-default' : 'github-dark-default')
158
+ }
159
+
160
+ /**
161
+ * Tokenize a whole file into per-line runs. Undefined until a lazy grammar
162
+ * finishes loading (caller re-renders via {@link subscribeGrammarLoaded}).
163
+ *
164
+ * Diff reconstructions are not real files: a hunk often starts inside
165
+ * `export default {` or an unclosed `/*`, and Shiki then paints the following
166
+ * added statements as object keys or comments — keywords go missing. Comment
167
+ * lines keep the file-level pass (JSDoc ` * `); every other line is re-lexed
168
+ * on its own so `async` / `function` / `const` colour as they would at the
169
+ * top level.
170
+ * @param lines - source lines, no leading +/-.
171
+ * @param lang - from {@link shikiLangOf}.
172
+ * @param theme - from {@link shikiThemeOf}.
173
+ */
174
+ export function highlightFile(
175
+ lines: readonly string[],
176
+ lang: string | undefined,
177
+ theme = 'github-dark-default',
178
+ ): HighlightRun[][] | undefined {
179
+ const fileTok = tokenizeLines(lines, lang, theme)
180
+ if (fileTok === undefined) return undefined
181
+ return lines.map((line, i) => {
182
+ const together = fileTok[i] ?? [{ text: line, color: undefined }]
183
+ if (looksLikeCommentLine(line)) return together
184
+ const solo = tokenizeLines([line], lang, theme)?.[0]
185
+ return solo !== undefined && solo.length > 0 ? solo : together
186
+ })
187
+ }
188
+
189
+ function looksLikeCommentLine(text: string): boolean {
190
+ const t = text.trimStart()
191
+ return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#')
192
+ }
193
+
194
+ function tokenizeLines(
195
+ lines: readonly string[],
196
+ lang: string | undefined,
197
+ theme: string,
198
+ ): HighlightRun[][] | undefined {
199
+ if (lang === undefined || lines.length === 0) return undefined
200
+ if (!ensureGrammar(lang)) return undefined
201
+ const { tokens } = highlighter().codeToTokens(lines.join('\n'), { lang, theme })
202
+ const last = tokens[tokens.length - 1]
203
+ const rows = tokens.length > 1 && last !== undefined && last.length === 0
204
+ ? tokens.slice(0, -1)
205
+ : tokens
206
+ const out: HighlightRun[][] = rows.map(line => line.map(token => ({
207
+ text: token.content,
208
+ color: token.color,
209
+ italic: token.fontStyle !== undefined && (token.fontStyle & 1) !== 0 ? true : undefined,
210
+ })))
211
+ while (out.length < lines.length) out.push([{ text: lines[out.length]!, color: undefined }])
212
+ return out.slice(0, lines.length)
213
+ }
214
+
215
+ /**
216
+ * Per-row Shiki runs for a unified diff: deletions from the old side,
217
+ * additions and context from the new side, each side highlighted as one file.
218
+ * @param rows - parsed unified-diff rows.
219
+ * @param lang - from {@link shikiLangOf}.
220
+ * @param theme - from {@link shikiThemeOf}.
221
+ */
222
+ export function highlightForRows(
223
+ rows: readonly Row[],
224
+ lang: string | undefined,
225
+ theme = 'github-dark-default',
226
+ ): HighlightRun[][] {
227
+ const oldLines: string[] = []
228
+ const newLines: string[] = []
229
+ const oldAt: number[] = []
230
+ const newAt: number[] = []
231
+ for (const row of rows) {
232
+ if (row.kind === 'del') {
233
+ oldAt.push(oldLines.length)
234
+ newAt.push(-1)
235
+ oldLines.push(row.text)
236
+ } else if (row.kind === 'add') {
237
+ oldAt.push(-1)
238
+ newAt.push(newLines.length)
239
+ newLines.push(row.text)
240
+ } else if (row.kind === 'context') {
241
+ oldAt.push(oldLines.length)
242
+ newAt.push(newLines.length)
243
+ oldLines.push(row.text)
244
+ newLines.push(row.text)
245
+ } else {
246
+ oldAt.push(-1)
247
+ newAt.push(-1)
248
+ }
249
+ }
250
+ const oldTok = highlightFile(oldLines, lang, theme)
251
+ const newTok = highlightFile(newLines, lang, theme)
252
+ return rows.map((row, i) => {
253
+ if (row.kind === 'del') return oldTok?.[oldAt[i]!] ?? [{ text: row.text, color: undefined }]
254
+ if (row.kind === 'add' || row.kind === 'context') return newTok?.[newAt[i]!] ?? [{ text: row.text, color: undefined }]
255
+ return [{ text: row.text, color: undefined }]
256
+ })
257
+ }