feat(mobile): GitHub-style diff rows with a pinned gutter and sideways scroll (#40961)

This commit is contained in:
Kelvin Amoaba
2026-11-11 04:18:55 +00:00
committed by GitHub
parent aba6ec4c4c
commit dbd3454ee1
6 changed files with 417 additions and 117 deletions
+145 -45
View File
@@ -1,5 +1,5 @@
import { memo } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Platform, Pressable, Text, View, type ViewStyle } from 'react-native'
import { MessageSquare } from 'lucide-react-native'
import type { Translate } from '../i18n/mobile-i18n'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
@@ -7,7 +7,7 @@ import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { MobileDiffLine } from '../source-control/review/mobile-diff-lines'
import type { MobileHighlightedDiffLine } from '../files/session-documents/mobile-file-syntax'
import { mobileDiffLineNumber, mobileDiffLinePrefix } from '../source-control/mobile-diff-format'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { colors, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { MobileSyntaxSegments } from './MobileSyntaxSegments'
@@ -21,6 +21,8 @@ type Props = {
staleCommentIds: ReadonlySet<string>
/** Part of the lines picked for a note. */
selected?: boolean
/** Fits the file's longest line number, so every row's gutter lines up. */
numberWidth: number
/** Absent where lines take no notes, e.g. a past commit's files. */
onAddNote?: (lineNumber: number) => void
onEditNote: (comment: DiffComment) => void
@@ -52,6 +54,7 @@ export const MobileDiffReviewLine = memo(function MobileDiffReviewLine({
comments,
staleCommentIds,
selected = false,
numberWidth,
onAddNote,
onEditNote
}: Props) {
@@ -59,23 +62,45 @@ export const MobileDiffReviewLine = memo(function MobileDiffReviewLine({
useMobileColorScheme()
const lineNumber = mobileDiffLineNumber(line)
const canComment = onAddNote !== undefined && canCommentOnLine(line)
const ground = [
line.kind === 'add' && styles.addedRow,
line.kind === 'delete' && styles.deletedRow,
selected && styles.selectedRow
]
return (
<View
style={[
styles.row,
line.kind === 'add' && styles.addedRow,
line.kind === 'delete' && styles.deletedRow,
selected && styles.selectedRow
]}
style={[styles.row, ...ground]}
accessible
accessibilityLabel={accessibilityLabelForLine(line, t)}
{...(canComment && line.newLineNumber !== undefined
? { nativeID: `${DIFF_LINE_ID_PREFIX}${line.newLineNumber}` }
: {})}
>
<Text style={styles.prefix}>{mobileDiffLinePrefix(line.kind)}</Text>
<Text style={styles.lineNumber}>{lineNumber ? String(lineNumber) : ''}</Text>
{/* Pinned while the file scrolls sideways, so it repaints the row's tint over an opaque ground. */}
<View style={[styles.pinned, styles.pinnedLeft]}>
<View style={[styles.gutter, ...ground]}>
<Text
style={[
styles.lineNumber,
{ width: numberWidth },
line.kind === 'add' && styles.addedNumber,
line.kind === 'delete' && styles.deletedNumber
]}
>
{lineNumber ? String(lineNumber) : ''}
</Text>
<Text
style={[
styles.sign,
line.kind === 'add' && styles.addedSign,
line.kind === 'delete' && styles.deletedSign
]}
>
{mobileDiffLinePrefix(line.kind)}
</Text>
</View>
</View>
<Pressable
style={({ pressed }) => [styles.code, pressed && canComment && styles.codePressed]}
disabled={!canComment}
@@ -98,41 +123,83 @@ export const MobileDiffReviewLine = memo(function MobileDiffReviewLine({
</Text>
</Pressable>
{comments.length > 0 ? (
<View style={styles.notes}>
{comments.map((comment) => (
<Pressable
key={comment.id}
style={({ pressed }) => [styles.noteButton, pressed && styles.noteButtonPressed]}
onPress={() => onEditNote(comment)}
accessibilityRole="button"
accessibilityLabel={t(
'components.diffReviewLine.editNoteOnLine',
'Edit note on line {{number}}',
{
number: comment.lineNumber
}
)}
>
<MessageSquare
size={13}
color={staleCommentIds.has(comment.id) ? colors.statusAmber : colors.textSecondary}
strokeWidth={2}
/>
</Pressable>
))}
<View style={[styles.pinned, styles.pinnedRight]}>
<View style={[styles.notes, ...ground]}>
{comments.map((comment) => (
<Pressable
key={comment.id}
style={({ pressed }) => [styles.noteButton, pressed && styles.noteButtonPressed]}
onPress={() => onEditNote(comment)}
accessibilityRole="button"
accessibilityLabel={t(
'components.diffReviewLine.editNoteOnLine',
'Edit note on line {{number}}',
{
number: comment.lineNumber
}
)}
>
<MessageSquare
size={13}
color={
staleCommentIds.has(comment.id) ? colors.statusAmber : colors.textSecondary
}
strokeWidth={2}
/>
</Pressable>
))}
</View>
</View>
) : null}
</View>
)
})
/** Renders one code line's worth of a known string, so the list can size its columns to the code font. */
export function MobileDiffReviewCharWidthProbe({
onCharWidth
}: {
onCharWidth: (width: number) => void
}) {
return (
<Text
aria-hidden
style={[styles.codeText, styles.probe]}
onLayout={(event) => onCharWidth(event.nativeEvent.layout.width / PROBE_TEXT.length)}
>
{PROBE_TEXT}
</Text>
)
}
const PROBE_TEXT = '0'.repeat(100)
const SIGN_WIDTH = 14
// Room either side of the line number.
const NUMBER_PADDING = 12
export function mobileDiffReviewGutterWidth(numberWidth: number): number {
return numberWidth + SIGN_WIDTH
}
export function mobileDiffReviewNumberWidth(digits: number, charWidth: number): number {
return Math.ceil(digits * charWidth + NUMBER_PADDING)
}
// The page lets long lines run and the file scroll sideways under pinned cells (Kelvin's choice over wrapping).
const webPinned: ViewStyle =
Platform.OS === 'web'
? {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: RN Web renders CSS sticky; this branch never reaches native.
position: 'sticky' as ViewStyle['position'],
zIndex: 1
}
: {}
// Use 18px lineHeight alone without padding/minHeight to match desktop diff density (STA-1239).
const styles = themedStyleSheet(() => ({
row: {
flexDirection: 'row',
alignItems: 'stretch',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
alignItems: 'stretch'
},
addedRow: {
backgroundColor: colors.diffAddedBg
@@ -144,38 +211,71 @@ const styles = themedStyleSheet(() => ({
selectedRow: {
backgroundColor: colors.skillTokenSurface
},
prefix: {
width: 18,
textAlign: 'center',
color: colors.textMuted,
pinned: {
...webPinned,
backgroundColor: colors.bgBase
},
pinnedLeft: {
left: 0
},
pinnedRight: {
right: 0
},
gutter: {
flex: 1,
flexDirection: 'row'
},
lineNumber: {
paddingRight: 6,
textAlign: 'right',
color: colors.diffLineNumber,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
lineHeight: 18
},
lineNumber: {
width: 44,
paddingRight: spacing.xs,
textAlign: 'right',
color: colors.textMuted,
addedNumber: {
color: colors.textPrimary,
backgroundColor: colors.diffAddedGutter
},
deletedNumber: {
color: colors.textPrimary,
backgroundColor: colors.diffDeletedGutter
},
sign: {
width: SIGN_WIDTH,
textAlign: 'center',
color: colors.diffLineNumber,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
lineHeight: 18
},
addedSign: {
color: colors.diffAddedSign
},
deletedSign: {
color: colors.diffDeletedSign
},
code: {
flex: 1,
minWidth: 0,
paddingHorizontal: spacing.sm
paddingRight: 10
},
codePressed: {
backgroundColor: colors.bgRaised
},
codeText: {
...(Platform.OS === 'web' ? { whiteSpace: 'pre' as const } : {}),
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 18
},
probe: {
position: 'absolute',
opacity: 0
},
notes: {
flex: 1,
width: 40,
alignItems: 'center',
justifyContent: 'center',
+135 -56
View File
@@ -1,11 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FlatList, Platform, Pressable, Text, View } from 'react-native'
import { FlatList, Platform, Pressable, Text, View, type ViewStyle } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { ChevronsUpDown } from 'lucide-react-native'
import { UnfoldVertical } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { DIFF_LINE_ID_PREFIX, MobileDiffReviewLine } from './MobileDiffReviewLine'
import {
DIFF_LINE_ID_PREFIX,
MobileDiffReviewCharWidthProbe,
MobileDiffReviewLine,
mobileDiffReviewGutterWidth,
mobileDiffReviewNumberWidth
} from './MobileDiffReviewLine'
import type { ReviewDiffLine } from '../source-control/review/mobile-diff-review-screen-model'
import {
buildMobileDiffReviewRows,
@@ -25,6 +31,18 @@ const RANGE_PICK_LONG_PRESS_MS = 350
const MAX_SCROLL_ATTEMPTS = 10
const NO_LINE_NOTES: readonly DiffComment[] = []
const SCROLL_RETRY_MS = 100
// The page's default tab-size, so a tabbed line's width matches what it draws.
const TAB_COLUMNS = 8
const CODE_END_PADDING = 10
const NOTES_WIDTH = 40
function lineColumns(text: string): number {
let columns = 0
for (const char of text) {
columns = char === '\t' ? (Math.floor(columns / TAB_COLUMNS) + 1) * TAB_COLUMNS : columns + 1
}
return columns
}
export type LineRange = { startLine: number; line: number }
@@ -77,6 +95,44 @@ export function MobileDiffReviewLines({
setOpened({ lines, indexes: next })
}
// Long lines run on one line and the whole file scrolls sideways, so every row is as wide as the longest.
const [charWidth, setCharWidth] = useState(0)
const [viewportWidth, setViewportWidth] = useState(0)
const { numberWidth, codeColumns } = useMemo(() => {
let digits = 1
let columns = 0
for (const line of lines) {
digits = Math.max(digits, String(line.newLineNumber ?? line.oldLineNumber ?? '').length)
columns = Math.max(columns, lineColumns(line.text))
}
return { numberWidth: mobileDiffReviewNumberWidth(digits, charWidth), codeColumns: columns }
}, [charWidth, lines])
const gutterWidth = mobileDiffReviewGutterWidth(numberWidth)
const rowFrame: ViewStyle =
Platform.OS === 'web' && charWidth > 0
? {
width: Math.max(
viewportWidth,
Math.ceil(
gutterWidth +
codeColumns * charWidth +
CODE_END_PADDING +
(commentsByLine.size > 0 ? NOTES_WIDTH : 0)
)
)
}
: {}
// Folds, threads and the footer hold still in the viewport while the code slides under them.
const viewportRow: ViewStyle =
Platform.OS === 'web' && viewportWidth > 0
? {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: RN Web renders CSS sticky; this branch never reaches native.
position: 'sticky' as ViewStyle['position'],
left: 0,
width: viewportWidth
}
: {}
const listRef = useRef<FlatList<MobileDiffReviewRow<ReviewDiffLine>>>(null)
const scrollFailedRef = useRef(false)
const [scrollAttempt, setScrollAttempt] = useState(0)
@@ -204,10 +260,16 @@ export function MobileDiffReviewLines({
return (
<GestureDetector gesture={pick}>
<View ref={frameRef} style={styles.diffListFrame}>
<View
ref={frameRef}
style={styles.diffListFrame}
onLayout={(event) => setViewportWidth(event.nativeEvent.layout.width)}
>
<MobileDiffReviewCharWidthProbe onCharWidth={setCharWidth} />
<FlatList<MobileDiffReviewRow<ReviewDiffLine>>
ref={listRef}
data={rows}
style={styles.diffList}
// The list holds still while lines are being picked, so the drag selects instead of scrolling.
scrollEnabled={dragRange === null}
onScrollToIndexFailed={(info) => {
@@ -218,60 +280,77 @@ export function MobileDiffReviewLines({
})
}}
keyExtractor={(row) => row.key}
renderItem={({ item: row }) =>
row.kind === 'fold' ? (
<Pressable
style={({ pressed }) => [styles.foldRow, pressed && styles.buttonPressed]}
onPress={() => openFold(row.startIndex, row.endIndex)}
accessibilityRole="button"
accessibilityLabel={t(
'components.diffReviewBody.showUnchangedLines',
'Show {{count}} unchanged lines',
{ count: row.hiddenCount }
)}
>
<ChevronsUpDown size={13} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.foldText}>
{row.hiddenCount === 1
? t(
'components.diffReviewBody.unchangedLines_one',
'{{count}} unchanged line',
{
count: row.hiddenCount
}
)
: t(
'components.diffReviewBody.unchangedLines_other',
'{{count}} unchanged lines',
{ count: row.hiddenCount }
)}
</Text>
</Pressable>
) : row.kind === 'thread' ? (
<View style={styles.threadRow}>
<CommentGroupView group={row.thread} actions={threadActions} now={now} />
</View>
) : (
<MobileDiffReviewLine
line={row.line}
comments={commentsByLine.get(row.line.newLineNumber ?? -1) ?? NO_LINE_NOTES}
staleCommentIds={staleCommentIds}
selected={
highlighted !== null &&
row.line.kind !== 'delete' &&
row.line.newLineNumber !== undefined &&
row.line.newLineNumber >= highlighted.startLine &&
row.line.newLineNumber <= highlighted.line
}
{...(onPickLines ? { onAddNote: pickOneLine } : {})}
onEditNote={onEditNote}
/>
)
}
contentContainerStyle={styles.diffList}
renderItem={({ item: row }) => (
<View style={rowFrame}>
{row.kind === 'fold' ? (
<Pressable
style={({ pressed }) => [
styles.foldRow,
viewportRow,
pressed && styles.buttonPressed
]}
onPress={() => openFold(row.startIndex, row.endIndex)}
accessibilityRole="button"
accessibilityLabel={t(
'components.diffReviewBody.showUnchangedLines',
'Show {{count}} unchanged lines',
{ count: row.hiddenCount }
)}
>
<View style={[styles.foldIcon, { width: gutterWidth }]}>
<UnfoldVertical size={14} color={colors.diffFoldText} strokeWidth={2} />
</View>
<View style={styles.foldBody}>
<Text style={styles.foldText}>
{row.hiddenCount === 1
? t(
'components.diffReviewBody.unchangedLines_one',
'{{count}} unchanged line',
{
count: row.hiddenCount
}
)
: t(
'components.diffReviewBody.unchangedLines_other',
'{{count}} unchanged lines',
{ count: row.hiddenCount }
)}
</Text>
{row.enclosingLine ? (
<Text style={styles.foldContext} numberOfLines={1}>
{row.enclosingLine}
</Text>
) : null}
</View>
</Pressable>
) : row.kind === 'thread' ? (
<View style={[styles.threadRow, viewportRow]}>
<CommentGroupView group={row.thread} actions={threadActions} now={now} />
</View>
) : (
<MobileDiffReviewLine
line={row.line}
comments={commentsByLine.get(row.line.newLineNumber ?? -1) ?? NO_LINE_NOTES}
staleCommentIds={staleCommentIds}
numberWidth={numberWidth}
selected={
highlighted !== null &&
row.line.kind !== 'delete' &&
row.line.newLineNumber !== undefined &&
row.line.newLineNumber >= highlighted.startLine &&
row.line.newLineNumber <= highlighted.line
}
{...(onPickLines ? { onAddNote: pickOneLine } : {})}
onEditNote={onEditNote}
/>
)}
</View>
)}
contentContainerStyle={styles.diffListContent}
ListFooterComponentStyle={rowFrame}
ListFooterComponent={
truncated ? (
<Text style={styles.truncatedText}>
<Text style={[styles.truncatedText, viewportRow]}>
{t('components.diffReviewBody.diffTruncated', 'Diff truncated for mobile preview.')}
</Text>
) : null
@@ -1,4 +1,4 @@
import { StyleSheet } from 'react-native'
import { Platform, StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
@@ -59,13 +59,31 @@ export const mobileDiffReviewLayoutStyles = themedStyleSheet(() => ({
foldRow: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'stretch',
backgroundColor: colors.diffFoldBg
},
foldIcon: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.bgPanel
backgroundColor: colors.diffFoldGutter
},
foldBody: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm
},
foldText: {
flexShrink: 0,
color: colors.diffFoldText,
fontSize: typography.metaSize
},
foldContext: {
flexShrink: 1,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
diffListFrame: {
@@ -77,8 +95,14 @@ export const mobileDiffReviewLayoutStyles = themedStyleSheet(() => ({
backgroundColor: colors.bgBase
},
diffList: {
paddingBottom: 140,
backgroundColor: colors.editorSurface
flex: 1,
backgroundColor: colors.bgBase
},
// On the page the rows run as wide as the longest line and scroll sideways together. A sideways scroller inside
// the vertical list keeps each swipe on one axis, as nested scrollers do in WebKit.
diffListContent: {
...(Platform.OS === 'web' ? { overflowX: 'auto' as const } : {}),
paddingBottom: 140
},
truncatedText: {
color: colors.textMuted,
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { MobileDiffLine } from './mobile-diff-lines'
import { foldEnclosingLine } from './mobile-diff-review-rows'
const context = (text: string): MobileDiffLine => ({ kind: 'context', text })
describe('foldEnclosingLine', () => {
it('names the nearest line above the fold that is indented less than the line after it', () => {
const lines: MobileDiffLine[] = [
context("describe('queue', () => {"),
context(" it('orders files', () => {"),
context(' const queue = build()'),
context(''),
context(' expect(queue).toHaveLength(3)'),
{ kind: 'delete', text: ' removed()' },
context(' expect(queue[0].scope).toBe(1)')
]
expect(foldEnclosingLine(lines, 6)).toBe("it('orders files', () => {")
})
it('skips blank lines after the fold and returns null when nothing encloses it', () => {
const lines = [
context('const a = 1'),
context('const b = 2'),
context(''),
context('const c = 3')
]
expect(foldEnclosingLine(lines, 2)).toBeNull()
expect(foldEnclosingLine(lines, 4)).toBeNull()
})
})
@@ -7,7 +7,15 @@ const MINIMUM_FOLDED_LINES = 3
export type MobileDiffReviewRow<Line extends MobileDiffLine = MobileDiffLine> =
| { kind: 'line'; key: string; line: Line; lineIndex: number }
| { kind: 'fold'; key: string; hiddenCount: number; startIndex: number; endIndex: number }
| {
kind: 'fold'
key: string
hiddenCount: number
startIndex: number
endIndex: number
/** The line the folded code sits inside, shown on the fold like GitHub's hunk header. */
enclosingLine: string | null
}
| { kind: 'thread'; key: string; thread: MobileDiffReviewThread }
/**
@@ -72,7 +80,8 @@ export function buildMobileDiffReviewRows<Line extends MobileDiffLine>(
key: `fold:${foldStart}`,
hiddenCount: foldEnd - foldStart,
startIndex: foldStart,
endIndex: foldEnd
endIndex: foldEnd,
enclosingLine: foldEnclosingLine(lines, foldEnd)
})
lineIndex = foldEnd - 1
continue
@@ -82,3 +91,47 @@ export function buildMobileDiffReviewRows<Line extends MobileDiffLine>(
}
return rows
}
const TAB_COLUMNS = 4
function indentColumns(text: string): number {
let columns = 0
for (const char of text) {
if (char === ' ') {
columns += 1
} else if (char === '\t') {
columns += TAB_COLUMNS
} else {
break
}
}
return columns
}
/**
* The phone's diff has no `@@` headers, so the fold names its scope itself: the nearest new-file line at or
* above the fold's end that is indented less than the first line shown after it. Blank lines don't count.
*/
export function foldEnclosingLine(
lines: readonly MobileDiffLine[],
foldEnd: number
): string | null {
const counts = (line: MobileDiffLine | undefined): line is MobileDiffLine =>
line !== undefined && line.kind !== 'delete' && line.text.trim() !== ''
let nextIndex = foldEnd
while (nextIndex < lines.length && !counts(lines[nextIndex])) {
nextIndex += 1
}
const next = lines[nextIndex]
if (!counts(next)) {
return null
}
const nextIndent = indentColumns(next.text)
for (let index = foldEnd - 1; index >= 0; index -= 1) {
const line = lines[index]
if (counts(line) && indentColumns(line.text) < nextIndent) {
return line.text.trim()
}
}
return null
}
+22 -9
View File
@@ -57,11 +57,18 @@ const darkColors = {
statusPurple: '#d8b4fe',
gitDecorationAdded: '#81b88b',
gitDecorationDeleted: '#c74e39',
// Desktop's `--diff-*-ground` and `--diff-*-gutter` mixes.
diffAddedBg: 'rgba(129, 184, 139, 0.16)',
diffDeletedBg: 'rgba(199, 78, 57, 0.18)',
diffAddedGutter: 'rgba(129, 184, 139, 0.3)',
diffDeletedGutter: 'rgba(199, 78, 57, 0.32)',
// GitHub's (Primer) diff colours, Kelvin's choice for every phone diff: row ground, line-number cell over it,
// the +/− sign, unchanged line numbers, and the blue "unchanged lines" fold band.
diffAddedBg: 'rgba(46, 160, 67, 0.15)',
diffDeletedBg: 'rgba(248, 81, 73, 0.13)',
diffAddedGutter: 'rgba(63, 185, 80, 0.3)',
diffDeletedGutter: 'rgba(248, 81, 73, 0.3)',
diffAddedSign: '#3fb950',
diffDeletedSign: '#f85149',
diffLineNumber: '#6e7681',
diffFoldBg: 'rgba(56, 139, 253, 0.1)',
diffFoldGutter: 'rgba(56, 139, 253, 0.22)',
diffFoldText: '#4493f8',
// Desktop's `--chat-diff-*`: decoration mixed toward foreground for tinted rows.
chatDiffAdded: '#a5ccac',
chatDiffRemoved: '#d68273',
@@ -124,10 +131,16 @@ const lightColors: MobilePalette = {
statusPurple: '#9333ea',
gitDecorationAdded: '#587c0c',
gitDecorationDeleted: '#ad0707',
diffAddedBg: 'rgba(88, 124, 12, 0.13)',
diffDeletedBg: 'rgba(173, 7, 7, 0.11)',
diffAddedGutter: 'rgba(88, 124, 12, 0.26)',
diffDeletedGutter: 'rgba(173, 7, 7, 0.22)',
diffAddedBg: '#dafbe1',
diffDeletedBg: '#ffebe9',
diffAddedGutter: '#aceebb',
diffDeletedGutter: '#ffcecb',
diffAddedSign: '#1a7f37',
diffDeletedSign: '#cf222e',
diffLineNumber: '#6e7781',
diffFoldBg: '#ddf4ff',
diffFoldGutter: 'rgba(84, 174, 255, 0.4)',
diffFoldText: '#0969da',
chatDiffAdded: '#415a0b',
chatDiffRemoved: '#7c0808',