mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): match Codex diff syntax rendering #1004
This commit is contained in:
parent
cc1eb4ee3a
commit
c5ad527034
@ -1,6 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { WorkspaceDiffSurface } from './WorkspaceDiffSurface'
|
||||
@ -10,16 +9,12 @@ import {
|
||||
WorkspaceDiffSurface as ExportedWorkspaceDiffSurface,
|
||||
} from './WorkspaceCodeSurface'
|
||||
|
||||
const highlightRenderSpy = vi.hoisted(() => vi.fn())
|
||||
const highlightRequestSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('prism-react-renderer', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('prism-react-renderer')>()
|
||||
vi.mock('./workspaceDiffHighlightRuntime', () => {
|
||||
return {
|
||||
...actual,
|
||||
Highlight: (props: ComponentProps<typeof actual.Highlight>) => {
|
||||
highlightRenderSpy()
|
||||
return <actual.Highlight {...props} />
|
||||
},
|
||||
createWorkspaceDiffHighlightCacheKey: (path: string, value: string) => `${path}:${value}`,
|
||||
requestWorkspaceDiffHighlight: highlightRequestSpy,
|
||||
}
|
||||
})
|
||||
|
||||
@ -43,10 +38,26 @@ function getCodeRow(text: string) {
|
||||
return row!
|
||||
}
|
||||
|
||||
function createHighlightResult(files: Array<{ rows: Array<{ id: string; text: string; selectable: boolean }> }>) {
|
||||
const tokensByRowId: Record<string, Array<{ content: string; color: string }>> = {}
|
||||
files.flatMap((file) => file.rows)
|
||||
.filter((row) => row.selectable)
|
||||
.forEach((row) => {
|
||||
tokensByRowId[row.id] = row.text.split(/(const)/).filter(Boolean).map((content) => ({
|
||||
content,
|
||||
color: content === 'const'
|
||||
? 'var(--color-diff-syntax-keyword)'
|
||||
: 'var(--color-diff-syntax-foreground)',
|
||||
}))
|
||||
})
|
||||
return { engine: 'shiki', tokensByRowId, wordRangesByRowId: {} }
|
||||
}
|
||||
|
||||
describe('WorkspaceDiffSurface', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
highlightRenderSpy.mockClear()
|
||||
highlightRequestSpy.mockReset()
|
||||
highlightRequestSpy.mockImplementation(() => new Promise(() => {}))
|
||||
})
|
||||
|
||||
it('keeps one scroll surface while hiding redundant single-file patch chrome', () => {
|
||||
@ -338,7 +349,7 @@ describe('WorkspaceDiffSurface', () => {
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Keep this collapsed draft')
|
||||
})
|
||||
|
||||
it('uses plain text instead of Prism after expanding a diff beyond the large preview threshold', () => {
|
||||
it('uses plain text instead of Shiki after expanding a diff beyond the large preview threshold', () => {
|
||||
const additions = Array.from(
|
||||
{ length: WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + 1 },
|
||||
(_, index) => `+const value${index} = ${index}`,
|
||||
@ -354,7 +365,8 @@ describe('WorkspaceDiffSurface', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
|
||||
expect(document.querySelector('.token')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'plain')
|
||||
expect(highlightRequestSpy).not.toHaveBeenCalled()
|
||||
expect(getCodeRow('const value5000 = 5000')).toHaveTextContent('const value5000 = 5000')
|
||||
})
|
||||
|
||||
@ -377,14 +389,78 @@ describe('WorkspaceDiffSurface', () => {
|
||||
expect(headers[1]).toHaveTextContent('diff --git a/src/b.ts b/src/b.ts')
|
||||
})
|
||||
|
||||
it('renders TypeScript Prism tokens through the compatibility export without a circular runtime failure', () => {
|
||||
it('renders TypeScript Shiki tokens through the compatibility export without a circular runtime failure', async () => {
|
||||
highlightRequestSpy.mockImplementationOnce(async ({ files }) => createHighlightResult(files))
|
||||
render(<ExportedWorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
|
||||
const keyword = screen.getAllByText('const').find((element) => element.classList.contains('keyword'))
|
||||
expect(keyword).toHaveClass('token', 'keyword')
|
||||
await waitFor(() => expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'shiki'))
|
||||
const keyword = screen.getAllByText('const').find((element) => (
|
||||
element.getAttribute('style')?.includes('var(--color-diff-syntax-keyword)')
|
||||
))
|
||||
expect(keyword).toBeDefined()
|
||||
expect(document.querySelectorAll('[data-row-text="const b = 3"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('never renders tokens from the previous diff while the next highlight is pending', async () => {
|
||||
highlightRequestSpy.mockImplementationOnce(async ({ files }) => {
|
||||
const result = createHighlightResult(files)
|
||||
const firstRow = files.flatMap((file: { rows: Array<{ id: string; selectable: boolean }> }) => file.rows)
|
||||
.find((row: { selectable: boolean }) => row.selectable)!
|
||||
result.tokensByRowId[firstRow.id] = [{
|
||||
content: 'STALE_TOKEN',
|
||||
color: 'var(--color-diff-syntax-keyword)',
|
||||
}]
|
||||
return result
|
||||
})
|
||||
const view = render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
await screen.findByText('STALE_TOKEN')
|
||||
|
||||
let resolveNext: (() => void) | undefined
|
||||
const nextDiff = diff.replace('const a = 1', 'let fresh = 2')
|
||||
highlightRequestSpy.mockImplementationOnce(({ files }) => new Promise((resolve) => {
|
||||
resolveNext = () => resolve(createHighlightResult(files))
|
||||
}))
|
||||
view.rerender(<WorkspaceDiffSurface value={nextDiff} path="src/a.ts" />)
|
||||
|
||||
expect(screen.queryByText('STALE_TOKEN')).not.toBeInTheDocument()
|
||||
expect(getCodeRow('let fresh = 2')).toHaveTextContent('let fresh = 2')
|
||||
|
||||
await act(async () => resolveNext?.())
|
||||
await waitFor(() => expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'shiki'))
|
||||
})
|
||||
|
||||
it('layers word-level changes over Shiki tokens without changing the line layout', async () => {
|
||||
const wordDiff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-const label = oldName',
|
||||
'+const label = newName',
|
||||
].join('\n')
|
||||
highlightRequestSpy.mockImplementationOnce(async ({ files }) => {
|
||||
const result = createHighlightResult(files)
|
||||
const rows = files.flatMap((file: { rows: Array<{ id: string; text: string }> }) => file.rows)
|
||||
const oldRow = rows.find((row: { text: string }) => row.text.includes('oldName'))!
|
||||
const newRow = rows.find((row: { text: string }) => row.text.includes('newName'))!
|
||||
return {
|
||||
...result,
|
||||
wordRangesByRowId: {
|
||||
[oldRow.id]: [{ start: 14, end: 21 }],
|
||||
[newRow.id]: [{ start: 14, end: 21 }],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
render(<WorkspaceDiffSurface value={wordDiff} path="src/a.ts" />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'shiki'))
|
||||
expect(document.querySelector('[data-diff-word-change="deletion"]')).toHaveTextContent('oldName')
|
||||
expect(document.querySelector('[data-diff-word-change="addition"]')).toHaveTextContent('newName')
|
||||
expect(document.querySelector('[data-diff-word-change="deletion"]')?.className).toContain('color-diff-removed-word')
|
||||
expect(document.querySelector('[data-diff-word-change="addition"]')?.className).toContain('color-diff-added-word')
|
||||
})
|
||||
|
||||
it('renders the complete review flow in Chinese', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
@ -400,7 +476,7 @@ describe('WorkspaceDiffSurface', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent('只能选择同一侧、同一变更块中的行')
|
||||
})
|
||||
|
||||
it('does not rerun Prism highlighting for each controlled draft change', () => {
|
||||
it('does not request Shiki highlighting again for each controlled draft change', () => {
|
||||
const additions = Array.from(
|
||||
{ length: WORKSPACE_PREVIEW_LINE_LIMIT - 4 },
|
||||
(_, index) => `+const value${index + 1} = ${index + 1}`,
|
||||
@ -414,14 +490,14 @@ describe('WorkspaceDiffSurface', () => {
|
||||
].join('\n')
|
||||
render(<WorkspaceDiffSurface value={nearLimitDiff} path="src/near-limit.ts" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/near-limit.ts new line 1' }))
|
||||
const highlightCountBeforeTyping = highlightRenderSpy.mock.calls.length
|
||||
const highlightCountBeforeTyping = highlightRequestSpy.mock.calls.length
|
||||
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
||||
|
||||
fireEvent.change(editor, { target: { value: 'a' } })
|
||||
fireEvent.change(editor, { target: { value: 'ab' } })
|
||||
fireEvent.change(editor, { target: { value: 'abc' } })
|
||||
|
||||
expect(highlightRenderSpy).toHaveBeenCalledTimes(highlightCountBeforeTyping)
|
||||
expect(highlightRequestSpy).toHaveBeenCalledTimes(highlightCountBeforeTyping)
|
||||
expect(editor).toHaveValue('abc')
|
||||
})
|
||||
|
||||
|
||||
@ -18,6 +18,15 @@ import {
|
||||
type WorkspaceDiffRow,
|
||||
type WorkspaceDiffSelection,
|
||||
} from './workspaceDiffModel'
|
||||
import {
|
||||
type WorkspaceDiffHighlightResult,
|
||||
type WorkspaceDiffHighlightToken,
|
||||
type WorkspaceDiffWordRange,
|
||||
} from './workspaceDiffHighlighter'
|
||||
import {
|
||||
createWorkspaceDiffHighlightCacheKey,
|
||||
requestWorkspaceDiffHighlight,
|
||||
} from './workspaceDiffHighlightRuntime'
|
||||
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
|
||||
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
|
||||
@ -98,6 +107,70 @@ export const InlineHighlightedCode = memo(function InlineHighlightedCode({
|
||||
)
|
||||
})
|
||||
|
||||
function tokenStyle(token: WorkspaceDiffHighlightToken): CSSProperties {
|
||||
const fontStyle = token.fontStyle ?? 0
|
||||
return {
|
||||
color: token.color,
|
||||
fontStyle: fontStyle & 1 ? 'italic' : undefined,
|
||||
fontWeight: fontStyle & 2 ? 700 : undefined,
|
||||
textDecoration: [
|
||||
fontStyle & 4 ? 'underline' : '',
|
||||
fontStyle & 8 ? 'line-through' : '',
|
||||
].filter(Boolean).join(' ') || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function overlapsRange(start: number, end: number, ranges: WorkspaceDiffWordRange[]) {
|
||||
return ranges.some((range) => start < range.end && end > range.start)
|
||||
}
|
||||
|
||||
const HighlightedDiffLine = memo(function HighlightedDiffLine({
|
||||
row,
|
||||
tokens,
|
||||
wordRanges,
|
||||
}: {
|
||||
row: WorkspaceDiffRow
|
||||
tokens: WorkspaceDiffHighlightToken[]
|
||||
wordRanges: WorkspaceDiffWordRange[]
|
||||
}) {
|
||||
let offset = 0
|
||||
return (
|
||||
<>
|
||||
{tokens.map((token, tokenIndex) => {
|
||||
const tokenStart = offset
|
||||
const tokenEnd = tokenStart + token.content.length
|
||||
offset = tokenEnd
|
||||
const boundaries = new Set([tokenStart, tokenEnd])
|
||||
wordRanges.forEach((range) => {
|
||||
if (range.start > tokenStart && range.start < tokenEnd) boundaries.add(range.start)
|
||||
if (range.end > tokenStart && range.end < tokenEnd) boundaries.add(range.end)
|
||||
})
|
||||
const points = [...boundaries].sort((left, right) => left - right)
|
||||
return points.slice(0, -1).map((start, partIndex) => {
|
||||
const end = points[partIndex + 1]!
|
||||
const changed = overlapsRange(start, end, wordRanges)
|
||||
return (
|
||||
<span
|
||||
key={`${tokenIndex}-${partIndex}`}
|
||||
data-diff-word-change={changed ? row.kind : undefined}
|
||||
className={changed
|
||||
? row.kind === 'addition'
|
||||
? 'bg-[var(--color-diff-added-word)]'
|
||||
: row.kind === 'deletion'
|
||||
? 'bg-[var(--color-diff-removed-word)]'
|
||||
: undefined
|
||||
: undefined}
|
||||
style={tokenStyle(token)}
|
||||
>
|
||||
{token.content.slice(start - tokenStart, end - tokenStart)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export interface WorkspaceDiffCommentSelection {
|
||||
side: 'old' | 'new'
|
||||
lineStart: number
|
||||
@ -124,6 +197,12 @@ interface ReviewState {
|
||||
|
||||
type ReviewStatus = 'selectionReset' | 'diffChanged' | 'collapsedSelection' | null
|
||||
|
||||
const plainHighlightResult: WorkspaceDiffHighlightResult = {
|
||||
engine: 'plain',
|
||||
tokensByRowId: {},
|
||||
wordRangesByRowId: {},
|
||||
}
|
||||
|
||||
const emptyReviewState: ReviewState = {
|
||||
anchorId: null,
|
||||
focusId: null,
|
||||
@ -202,7 +281,21 @@ export function WorkspaceDiffSurface({
|
||||
)
|
||||
const visibleRows = useMemo(() => rows.filter((row) => visibleItemIds.has(row.id)), [rows, visibleItemIds])
|
||||
const selectableRows = useMemo(() => visibleRows.filter((row) => row.selectable), [visibleRows])
|
||||
const usePlainLargePreview = showAllRows && rows.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
const usePlainLargePreview = rows.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
const highlightCacheKey = useMemo(
|
||||
() => createWorkspaceDiffHighlightCacheKey(path, value),
|
||||
[path, value],
|
||||
)
|
||||
const [highlightState, setHighlightState] = useState<{
|
||||
cacheKey: string | null
|
||||
result: WorkspaceDiffHighlightResult
|
||||
}>({
|
||||
cacheKey: null,
|
||||
result: plainHighlightResult,
|
||||
})
|
||||
const highlightResult = !usePlainLargePreview && highlightState.cacheKey === highlightCacheKey
|
||||
? highlightState.result
|
||||
: plainHighlightResult
|
||||
const [rovingId, setRovingId] = useState<string | null>(() => rows.find((row) => row.selectable)?.id ?? null)
|
||||
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||
@ -213,6 +306,22 @@ export function WorkspaceDiffSurface({
|
||||
const selectedIds = new Set(review.selection?.rowIds ?? [])
|
||||
const sideLabel = (side: 'old' | 'new') => t(`workspace.diffReview.side.${side}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (usePlainLargePreview) {
|
||||
setHighlightState({ cacheKey: null, result: plainHighlightResult })
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setHighlightState({ cacheKey: null, result: plainHighlightResult })
|
||||
requestWorkspaceDiffHighlight({ cacheKey: highlightCacheKey, files, path }).then((result) => {
|
||||
if (!cancelled) setHighlightState({ cacheKey: highlightCacheKey, result })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [files, highlightCacheKey, path, usePlainLargePreview])
|
||||
|
||||
useEffect(() => {
|
||||
const pathChanged = previousPath.current !== path
|
||||
const valueChanged = previousValue.current !== value
|
||||
@ -491,6 +600,7 @@ export function WorkspaceDiffSurface({
|
||||
<div
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
data-highlight-engine={highlightResult.engine}
|
||||
role="grid"
|
||||
aria-label={`${path} diff`}
|
||||
className="m-0 min-w-full font-[var(--font-mono)] text-[13px] leading-5 text-[var(--color-code-fg)]"
|
||||
@ -502,7 +612,6 @@ export function WorkspaceDiffSurface({
|
||||
if (!headerVisible && fileRows.length === 0) return null
|
||||
const oldPath = file.oldPath ? `a/${file.oldPath}` : '/dev/null'
|
||||
const newPath = file.newPath ? `b/${file.newPath}` : '/dev/null'
|
||||
const language = getLanguageFromPath(file.newPath ?? file.oldPath ?? path)
|
||||
const fileAdditions = file.rows.filter((row) => row.kind === 'addition').length
|
||||
const fileDeletions = file.rows.filter((row) => row.kind === 'deletion').length
|
||||
const displayPath = file.newPath ?? file.oldPath ?? path
|
||||
@ -611,8 +720,14 @@ export function WorkspaceDiffSurface({
|
||||
>
|
||||
<span className={`inline-block w-[2ch] select-none text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
|
||||
<span className={codeTone(row)}>
|
||||
{row.selectable && row.text && !usePlainLargePreview
|
||||
? <InlineHighlightedCode value={row.text} language={language} />
|
||||
{row.selectable && row.text && highlightResult.tokensByRowId[row.id]
|
||||
? (
|
||||
<HighlightedDiffLine
|
||||
row={row}
|
||||
tokens={highlightResult.tokensByRowId[row.id]!}
|
||||
wordRanges={highlightResult.wordRangesByRowId[row.id] ?? []}
|
||||
/>
|
||||
)
|
||||
: row.text || ' '}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const highlightWorkspaceDiffSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./workspaceDiffHighlighter', () => ({
|
||||
highlightWorkspaceDiff: highlightWorkspaceDiffSpy,
|
||||
}))
|
||||
|
||||
describe('workspaceDiffHighlight worker', () => {
|
||||
const postMessage = vi.fn()
|
||||
let workerScope: {
|
||||
onmessage?: (event: MessageEvent) => Promise<void>
|
||||
postMessage: typeof postMessage
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
workerScope = { postMessage }
|
||||
vi.stubGlobal('self', workerScope)
|
||||
await import('./workspaceDiffHighlight.worker')
|
||||
})
|
||||
|
||||
it('posts successful and failed highlight responses with the request id', async () => {
|
||||
const result = { engine: 'shiki', tokensByRowId: {}, wordRangesByRowId: {} }
|
||||
highlightWorkspaceDiffSpy.mockResolvedValueOnce(result)
|
||||
await workerScope.onmessage?.({
|
||||
data: { id: 7, files: [], path: 'src/a.ts' },
|
||||
} as MessageEvent)
|
||||
expect(postMessage).toHaveBeenLastCalledWith({ id: 7, result })
|
||||
|
||||
highlightWorkspaceDiffSpy.mockRejectedValueOnce(new Error('grammar failed'))
|
||||
await workerScope.onmessage?.({
|
||||
data: { id: 8, files: [], path: 'src/b.ts' },
|
||||
} as MessageEvent)
|
||||
expect(postMessage).toHaveBeenLastCalledWith({ id: 8, error: 'grammar failed' })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,20 @@
|
||||
import type { WorkspaceDiffFile } from './workspaceDiffModel'
|
||||
import { highlightWorkspaceDiff } from './workspaceDiffHighlighter'
|
||||
|
||||
interface HighlightRequest {
|
||||
id: number
|
||||
files: WorkspaceDiffFile[]
|
||||
path: string
|
||||
}
|
||||
self.onmessage = async (event: MessageEvent<HighlightRequest>) => {
|
||||
const { id, files, path } = event.data
|
||||
try {
|
||||
const result = await highlightWorkspaceDiff({ files, path })
|
||||
self.postMessage({ id, result })
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseWorkspaceDiff } from './workspaceDiffModel'
|
||||
import {
|
||||
createWorkspaceDiffHighlightCacheKey,
|
||||
requestWorkspaceDiffHighlight,
|
||||
} from './workspaceDiffHighlightRuntime'
|
||||
|
||||
const highlightWorkspaceDiffSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./workspaceDiffHighlighter', () => ({
|
||||
highlightWorkspaceDiff: highlightWorkspaceDiffSpy,
|
||||
}))
|
||||
|
||||
const files = parseWorkspaceDiff([
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-const before = true',
|
||||
'+const after = true',
|
||||
].join('\n'))
|
||||
|
||||
const highlighted = {
|
||||
engine: 'shiki' as const,
|
||||
tokensByRowId: {},
|
||||
wordRangesByRowId: {},
|
||||
}
|
||||
|
||||
const plain = {
|
||||
engine: 'plain' as const,
|
||||
tokensByRowId: {},
|
||||
wordRangesByRowId: {},
|
||||
}
|
||||
|
||||
describe.sequential('workspaceDiffHighlightRuntime', () => {
|
||||
beforeEach(() => {
|
||||
highlightWorkspaceDiffSpy.mockReset()
|
||||
highlightWorkspaceDiffSpy.mockResolvedValue(highlighted)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('builds stable content-aware cache keys', () => {
|
||||
expect(createWorkspaceDiffHighlightCacheKey('src/a.ts', 'one'))
|
||||
.toBe(createWorkspaceDiffHighlightCacheKey('src/a.ts', 'one'))
|
||||
expect(createWorkspaceDiffHighlightCacheKey('src/a.ts', 'one'))
|
||||
.not.toBe(createWorkspaceDiffHighlightCacheKey('src/a.ts', 'two'))
|
||||
expect(createWorkspaceDiffHighlightCacheKey('src/a.ts', 'one'))
|
||||
.not.toBe(createWorkspaceDiffHighlightCacheKey('src/b.ts', 'one'))
|
||||
expect(createWorkspaceDiffHighlightCacheKey('src/a.ts', '10fv6kn0uwrauy0osy0nl0b9tpfg'))
|
||||
.not.toBe(createWorkspaceDiffHighlightCacheKey('src/a.ts', '03rslpn0g53pe61v1qjpx1d4vzcg'))
|
||||
})
|
||||
|
||||
it('does not retain an oversized token result in the LRU cache', async () => {
|
||||
const originalWorker = globalThis.Worker
|
||||
vi.stubGlobal('Worker', undefined)
|
||||
const oversized = {
|
||||
...highlighted,
|
||||
tokensByRowId: {
|
||||
row: Array.from({ length: 20_001 }, () => ({ content: 'x' })),
|
||||
},
|
||||
}
|
||||
highlightWorkspaceDiffSpy.mockResolvedValue(oversized)
|
||||
const request = { cacheKey: 'runtime-oversized', files, path: 'src/a.ts' }
|
||||
|
||||
await requestWorkspaceDiffHighlight(request)
|
||||
await requestWorkspaceDiffHighlight(request)
|
||||
|
||||
expect(highlightWorkspaceDiffSpy).toHaveBeenCalledTimes(2)
|
||||
vi.stubGlobal('Worker', originalWorker)
|
||||
})
|
||||
|
||||
it('deduplicates pending work and reuses its cached result without a Worker', async () => {
|
||||
const originalWorker = globalThis.Worker
|
||||
vi.stubGlobal('Worker', undefined)
|
||||
let resolveHighlight: ((value: typeof highlighted) => void) | undefined
|
||||
highlightWorkspaceDiffSpy.mockReturnValue(new Promise((resolve) => {
|
||||
resolveHighlight = resolve
|
||||
}))
|
||||
const request = { cacheKey: 'runtime-fallback', files, path: 'src/a.ts' }
|
||||
|
||||
const first = requestWorkspaceDiffHighlight(request)
|
||||
const second = requestWorkspaceDiffHighlight(request)
|
||||
expect(first).toBe(second)
|
||||
await vi.waitFor(() => expect(highlightWorkspaceDiffSpy).toHaveBeenCalledOnce())
|
||||
|
||||
resolveHighlight?.(highlighted)
|
||||
await expect(first).resolves.toBe(highlighted)
|
||||
await expect(requestWorkspaceDiffHighlight(request)).resolves.toBe(highlighted)
|
||||
expect(highlightWorkspaceDiffSpy).toHaveBeenCalledOnce()
|
||||
vi.stubGlobal('Worker', originalWorker)
|
||||
})
|
||||
|
||||
it('accepts highlighted results from the worker boundary', async () => {
|
||||
class FakeWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onmessageerror: (() => void) | null = null
|
||||
|
||||
postMessage(message: { id: number }) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: { id: message.id, result: highlighted },
|
||||
} as MessageEvent)
|
||||
this.onerror?.()
|
||||
})
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
vi.stubGlobal('Worker', FakeWorker)
|
||||
|
||||
await expect(requestWorkspaceDiffHighlight({
|
||||
cacheKey: 'runtime-worker',
|
||||
files,
|
||||
path: 'src/a.ts',
|
||||
})).resolves.toBe(highlighted)
|
||||
expect(highlightWorkspaceDiffSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to plain text without replaying Shiki when the worker crashes', async () => {
|
||||
class FailingWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onmessageerror: (() => void) | null = null
|
||||
|
||||
postMessage() {
|
||||
queueMicrotask(() => this.onerror?.())
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
vi.stubGlobal('Worker', FailingWorker)
|
||||
|
||||
await expect(requestWorkspaceDiffHighlight({
|
||||
cacheKey: 'runtime-worker-error',
|
||||
files,
|
||||
path: 'src/a.ts',
|
||||
})).resolves.toEqual(plain)
|
||||
expect(highlightWorkspaceDiffSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up unreadable worker messages without replaying Shiki', async () => {
|
||||
class UnreadableWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onmessageerror: (() => void) | null = null
|
||||
|
||||
postMessage() {
|
||||
queueMicrotask(() => this.onmessageerror?.())
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
vi.stubGlobal('Worker', UnreadableWorker)
|
||||
|
||||
await expect(requestWorkspaceDiffHighlight({
|
||||
cacheKey: 'runtime-worker-message-error',
|
||||
files,
|
||||
path: 'src/a.ts',
|
||||
})).resolves.toEqual(plain)
|
||||
expect(highlightWorkspaceDiffSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up a synchronous postMessage failure without replaying Shiki', async () => {
|
||||
class ThrowingWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onmessageerror: (() => void) | null = null
|
||||
|
||||
postMessage() {
|
||||
throw new Error('clone failed')
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
vi.stubGlobal('Worker', ThrowingWorker)
|
||||
|
||||
await expect(requestWorkspaceDiffHighlight({
|
||||
cacheKey: 'runtime-worker-post-error',
|
||||
files,
|
||||
path: 'src/a.ts',
|
||||
})).resolves.toEqual(plain)
|
||||
expect(highlightWorkspaceDiffSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out an unresponsive worker and clears its pending request', async () => {
|
||||
vi.useFakeTimers()
|
||||
class SilentWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onmessageerror: (() => void) | null = null
|
||||
|
||||
postMessage() {}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
vi.stubGlobal('Worker', SilentWorker)
|
||||
|
||||
const request = requestWorkspaceDiffHighlight({
|
||||
cacheKey: 'runtime-worker-timeout',
|
||||
files,
|
||||
path: 'src/a.ts',
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
await expect(request).resolves.toEqual(plain)
|
||||
expect(highlightWorkspaceDiffSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,164 @@
|
||||
import type { WorkspaceDiffFile } from './workspaceDiffModel'
|
||||
import type { WorkspaceDiffHighlightResult } from './workspaceDiffHighlighter'
|
||||
|
||||
const WORKSPACE_DIFF_HIGHLIGHT_CACHE_SIZE = 100
|
||||
const WORKSPACE_DIFF_HIGHLIGHT_CACHE_WEIGHT = 100_000
|
||||
const WORKSPACE_DIFF_HIGHLIGHT_MAX_ENTRY_WEIGHT = 20_000
|
||||
const WORKSPACE_DIFF_HIGHLIGHT_TIMEOUT_MS = 15_000
|
||||
|
||||
interface HighlightRequest {
|
||||
cacheKey: string
|
||||
files: WorkspaceDiffFile[]
|
||||
path: string
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: WorkspaceDiffHighlightResult) => void
|
||||
reject: (error: Error) => void
|
||||
timeoutId: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface CachedHighlight {
|
||||
result: WorkspaceDiffHighlightResult
|
||||
weight: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedHighlight>()
|
||||
const pendingByCacheKey = new Map<string, Promise<WorkspaceDiffHighlightResult>>()
|
||||
const pendingById = new Map<number, PendingRequest>()
|
||||
let worker: Worker | null = null
|
||||
let requestId = 0
|
||||
let cacheWeight = 0
|
||||
|
||||
function createPlainHighlightResult(): WorkspaceDiffHighlightResult {
|
||||
return {
|
||||
engine: 'plain',
|
||||
tokensByRowId: {},
|
||||
wordRangesByRowId: {},
|
||||
}
|
||||
}
|
||||
|
||||
async function highlightOnMainThread(files: WorkspaceDiffFile[], path: string) {
|
||||
const { highlightWorkspaceDiff } = await import('./workspaceDiffHighlighter')
|
||||
return highlightWorkspaceDiff({ files, path })
|
||||
}
|
||||
|
||||
function failHighlightWorker(activeWorker: Worker, message: string) {
|
||||
if (worker !== activeWorker) return false
|
||||
worker = null
|
||||
activeWorker.terminate()
|
||||
const error = new Error(message)
|
||||
pendingById.forEach((pending) => {
|
||||
clearTimeout(pending.timeoutId)
|
||||
pending.reject(error)
|
||||
})
|
||||
pendingById.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
function getResultWeight(cacheKey: string, result: WorkspaceDiffHighlightResult) {
|
||||
const tokenCount = Object.values(result.tokensByRowId)
|
||||
.reduce((total, tokens) => total + tokens.length, 0)
|
||||
const wordRangeCount = Object.values(result.wordRangesByRowId)
|
||||
.reduce((total, ranges) => total + ranges.length, 0)
|
||||
return Math.ceil(cacheKey.length / 80) + tokenCount + wordRangeCount
|
||||
}
|
||||
|
||||
function setCached(cacheKey: string, result: WorkspaceDiffHighlightResult) {
|
||||
const weight = getResultWeight(cacheKey, result)
|
||||
if (weight > WORKSPACE_DIFF_HIGHLIGHT_MAX_ENTRY_WEIGHT) return
|
||||
|
||||
const previous = cache.get(cacheKey)
|
||||
if (previous) cacheWeight -= previous.weight
|
||||
cache.delete(cacheKey)
|
||||
cache.set(cacheKey, { result, weight })
|
||||
cacheWeight += weight
|
||||
while (
|
||||
cache.size > WORKSPACE_DIFF_HIGHLIGHT_CACHE_SIZE
|
||||
|| cacheWeight > WORKSPACE_DIFF_HIGHLIGHT_CACHE_WEIGHT
|
||||
) {
|
||||
const oldestKey = cache.keys().next().value
|
||||
if (oldestKey === undefined) break
|
||||
cacheWeight -= cache.get(oldestKey)?.weight ?? 0
|
||||
cache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
if (typeof Worker === 'undefined') return null
|
||||
|
||||
try {
|
||||
const nextWorker = new Worker(new URL('./workspaceDiffHighlight.worker.ts', import.meta.url), { type: 'module' })
|
||||
worker = nextWorker
|
||||
nextWorker.onmessage = (event: MessageEvent<{
|
||||
id: number
|
||||
result?: WorkspaceDiffHighlightResult
|
||||
error?: string
|
||||
}>) => {
|
||||
const pending = pendingById.get(event.data.id)
|
||||
if (!pending) return
|
||||
pendingById.delete(event.data.id)
|
||||
clearTimeout(pending.timeoutId)
|
||||
if (event.data.result) pending.resolve(event.data.result)
|
||||
else pending.reject(new Error(event.data.error || 'Diff highlighting failed'))
|
||||
}
|
||||
nextWorker.onerror = () => failHighlightWorker(nextWorker, 'Diff highlighting worker failed')
|
||||
nextWorker.onmessageerror = () => failHighlightWorker(nextWorker, 'Diff highlighting worker returned an unreadable result')
|
||||
return nextWorker
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function highlightInWorker(files: WorkspaceDiffFile[], path: string) {
|
||||
const activeWorker = getWorker()
|
||||
if (!activeWorker) return highlightOnMainThread(files, path)
|
||||
|
||||
requestId += 1
|
||||
const id = requestId
|
||||
return new Promise<WorkspaceDiffHighlightResult>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = pendingById.get(id)
|
||||
if (!pending) return
|
||||
if (!failHighlightWorker(activeWorker, 'Diff highlighting worker timed out')) {
|
||||
pendingById.delete(id)
|
||||
pending.reject(new Error('Diff highlighting worker timed out'))
|
||||
}
|
||||
}, WORKSPACE_DIFF_HIGHLIGHT_TIMEOUT_MS)
|
||||
pendingById.set(id, { resolve, reject, timeoutId })
|
||||
try {
|
||||
activeWorker.postMessage({ id, files, path })
|
||||
} catch (error) {
|
||||
if (!failHighlightWorker(activeWorker, 'Diff highlighting worker rejected the request')) {
|
||||
clearTimeout(timeoutId)
|
||||
pendingById.delete(id)
|
||||
reject(error instanceof Error ? error : new Error('Diff highlighting worker rejected the request'))
|
||||
}
|
||||
}
|
||||
}).catch(() => createPlainHighlightResult())
|
||||
}
|
||||
|
||||
export function createWorkspaceDiffHighlightCacheKey(path: string, value: string) {
|
||||
return `${path}\0${value}`
|
||||
}
|
||||
|
||||
export function requestWorkspaceDiffHighlight({ cacheKey, files, path }: HighlightRequest) {
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached) {
|
||||
setCached(cacheKey, cached.result)
|
||||
return Promise.resolve(cached.result)
|
||||
}
|
||||
|
||||
const existing = pendingByCacheKey.get(cacheKey)
|
||||
if (existing) return existing
|
||||
|
||||
const request = highlightInWorker(files, path)
|
||||
.then((result) => {
|
||||
setCached(cacheKey, result)
|
||||
return result
|
||||
})
|
||||
.finally(() => pendingByCacheKey.delete(cacheKey))
|
||||
pendingByCacheKey.set(cacheKey, request)
|
||||
return request
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseWorkspaceDiff } from './workspaceDiffModel'
|
||||
import {
|
||||
buildWorkspaceDiffWordRanges,
|
||||
highlightWorkspaceDiff,
|
||||
} from './workspaceDiffHighlighter'
|
||||
|
||||
function findRowId(diff: string, text: string) {
|
||||
const files = parseWorkspaceDiff(diff)
|
||||
const row = files.flatMap((file) => file.rows).find((candidate) => candidate.text === text)
|
||||
expect(row).toBeDefined()
|
||||
return { files, rowId: row!.id }
|
||||
}
|
||||
|
||||
describe('workspaceDiffHighlighter', () => {
|
||||
it('keeps TextMate grammar state across the rows in one diff hunk', async () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -0,0 +1,5 @@',
|
||||
'+const before = 1',
|
||||
'+/* start comment',
|
||||
'+continued comment',
|
||||
'+end comment */',
|
||||
'+const after = 2',
|
||||
].join('\n')
|
||||
const { files, rowId } = findRowId(diff, 'continued comment')
|
||||
|
||||
const result = await highlightWorkspaceDiff({ files, path: 'src/a.ts' })
|
||||
|
||||
expect(result.engine).toBe('shiki')
|
||||
expect(result.tokensByRowId[rowId]).toEqual([
|
||||
expect.objectContaining({
|
||||
content: 'continued comment',
|
||||
color: 'var(--color-diff-syntax-comment)',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('uses Codex-style TextMate scopes for TypeScript symbols', async () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+type ReviewState = { ready: boolean }',
|
||||
'+const reviewState: ReviewState = { ready: true }',
|
||||
].join('\n')
|
||||
const { files, rowId } = findRowId(diff, 'type ReviewState = { ready: boolean }')
|
||||
|
||||
const result = await highlightWorkspaceDiff({ files, path: 'src/a.ts' })
|
||||
const tokens = result.tokensByRowId[rowId] ?? []
|
||||
|
||||
expect(tokens.some((token) => token.content.includes('type') && token.color === 'var(--color-diff-syntax-keyword)')).toBe(true)
|
||||
expect(tokens.some((token) => token.content.includes('ReviewState') && token.color === 'var(--color-diff-syntax-type)')).toBe(true)
|
||||
})
|
||||
|
||||
it('marks only the changed words in paired deletion and addition lines', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-const label = formatUser(oldName)',
|
||||
'+const label = formatUser(newName)',
|
||||
].join('\n')
|
||||
const files = parseWorkspaceDiff(diff)
|
||||
const rows = files[0]!.rows
|
||||
const oldRow = rows.find((row) => row.text.includes('oldName'))!
|
||||
const newRow = rows.find((row) => row.text.includes('newName'))!
|
||||
|
||||
const ranges = buildWorkspaceDiffWordRanges(files)
|
||||
|
||||
expect(ranges[oldRow.id]).toEqual([{ start: 25, end: 32 }])
|
||||
expect(ranges[newRow.id]).toEqual([{ start: 25, end: 32 }])
|
||||
})
|
||||
|
||||
it('skips expensive word matching for very long lines', () => {
|
||||
const oldLine = `const value = '${'a'.repeat(1_001)}'`
|
||||
const newLine = `const value = '${'b'.repeat(1_001)}'`
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
`-${oldLine}`,
|
||||
`+${newLine}`,
|
||||
].join('\n')
|
||||
|
||||
expect(buildWorkspaceDiffWordRanges(parseWorkspaceDiff(diff))).toEqual({})
|
||||
})
|
||||
|
||||
it('does not guess word pairs inside unequal replacement blocks', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1,2 +1 @@',
|
||||
'-const before = firstValue',
|
||||
'-const target = oldName',
|
||||
'+const target = newName',
|
||||
].join('\n')
|
||||
|
||||
expect(buildWorkspaceDiffWordRanges(parseWorkspaceDiff(diff))).toEqual({})
|
||||
})
|
||||
|
||||
it('skips word ranges when equal-sized replacement lines are not similar', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1,2 +1,2 @@',
|
||||
'-const first = one',
|
||||
'-const second = two',
|
||||
'+const second = two',
|
||||
'+const first = one',
|
||||
].join('\n')
|
||||
|
||||
expect(buildWorkspaceDiffWordRanges(parseWorkspaceDiff(diff))).toEqual({})
|
||||
})
|
||||
})
|
||||
538
desktop/src/components/workspace/workspaceDiffHighlighter.ts
Normal file
538
desktop/src/components/workspace/workspaceDiffHighlighter.ts
Normal file
@ -0,0 +1,538 @@
|
||||
import { createHighlighterCore } from 'shiki/core'
|
||||
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||
import type { HighlighterCore, LanguageRegistration, ThemeRegistration } from 'shiki'
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffRow } from './workspaceDiffModel'
|
||||
|
||||
export const WORKSPACE_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000
|
||||
const WORKSPACE_DIFF_WORD_MAX_SEGMENTS = 240
|
||||
const WORKSPACE_DIFF_WORD_MIN_SIMILARITY = 0.6
|
||||
|
||||
export interface WorkspaceDiffHighlightToken {
|
||||
content: string
|
||||
color?: string
|
||||
fontStyle?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffWordRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffHighlightResult {
|
||||
engine: 'shiki' | 'plain'
|
||||
tokensByRowId: Record<string, WorkspaceDiffHighlightToken[]>
|
||||
wordRangesByRowId: Record<string, WorkspaceDiffWordRange[]>
|
||||
}
|
||||
|
||||
interface WordSegment extends WorkspaceDiffWordRange {
|
||||
text: string
|
||||
}
|
||||
|
||||
const workspaceDiffShikiTheme: ThemeRegistration = {
|
||||
name: 'codex-workspace-diff',
|
||||
type: 'dark',
|
||||
fg: 'var(--color-diff-syntax-foreground)',
|
||||
bg: 'transparent',
|
||||
settings: [
|
||||
{
|
||||
settings: {
|
||||
foreground: 'var(--color-diff-syntax-foreground)',
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'comment',
|
||||
'punctuation.definition.comment',
|
||||
'string.quoted.docstring',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-comment)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'string',
|
||||
'string.quoted',
|
||||
'string.template',
|
||||
'string.other.link',
|
||||
'markup.inline.raw.string.markdown',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-string)' },
|
||||
},
|
||||
{
|
||||
scope: ['string.regexp', 'constant.other.character-class.regexp'],
|
||||
settings: { foreground: 'var(--color-diff-syntax-regexp)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'constant.numeric',
|
||||
'constant.language.boolean',
|
||||
'constant.language.null',
|
||||
'constant.language.undefined',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-number)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'constant',
|
||||
'punctuation.definition.constant',
|
||||
'variable.other.constant',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-variable)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'keyword',
|
||||
'keyword.control',
|
||||
'storage',
|
||||
'storage.type',
|
||||
'storage.modifier',
|
||||
'keyword.operator.new',
|
||||
'keyword.operator.expression.instanceof',
|
||||
'keyword.operator.expression.typeof',
|
||||
'keyword.operator.expression.void',
|
||||
'keyword.operator.expression.delete',
|
||||
'keyword.operator.expression.in',
|
||||
'keyword.operator.expression.of',
|
||||
'keyword.operator.expression.keyof',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-keyword)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'meta.function-call',
|
||||
'meta.require',
|
||||
'support.function',
|
||||
'support.function.any-method',
|
||||
'variable.function',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-function)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.type',
|
||||
'entity.name.type.alias',
|
||||
'entity.name.class',
|
||||
'entity.other.inherited-class',
|
||||
'support.class',
|
||||
'support.type',
|
||||
'support.type.primitive',
|
||||
'support.type.primitive.ts',
|
||||
'support.type.builtin.ts',
|
||||
'support.type.primitive.tsx',
|
||||
'support.type.builtin.tsx',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-type)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.parameter',
|
||||
'meta.parameters variable.other.readwrite',
|
||||
'meta.parameter variable.other.readwrite',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-parameter)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'variable.other.object.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'support.variable.property',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-property)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable',
|
||||
'variable.other',
|
||||
'variable.other.readwrite',
|
||||
'variable.other.constant',
|
||||
'variable.other.enummember',
|
||||
'identifier',
|
||||
'meta.definition.variable',
|
||||
'entity.name.namespace',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-variable)' },
|
||||
},
|
||||
{
|
||||
scope: ['keyword.operator'],
|
||||
settings: { foreground: 'var(--color-diff-syntax-punctuation)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'keyword.operator.logical',
|
||||
'keyword.operator.bitwise',
|
||||
'keyword.operator.channel',
|
||||
'keyword.operator.arithmetic',
|
||||
'keyword.operator.comparison',
|
||||
'keyword.operator.relational',
|
||||
'keyword.operator.increment',
|
||||
'keyword.operator.decrement',
|
||||
'keyword.operator.assignment',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-number)' },
|
||||
},
|
||||
{
|
||||
scope: ['keyword.operator.assignment.compound'],
|
||||
settings: { foreground: 'var(--color-diff-syntax-keyword)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'keyword.operator.assignment.compound.js',
|
||||
'keyword.operator.assignment.compound.ts',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-number)' },
|
||||
},
|
||||
{
|
||||
scope: ['keyword.operator.ternary', 'keyword.operator.optional'],
|
||||
settings: { foreground: 'var(--color-diff-syntax-keyword)' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'punctuation',
|
||||
'punctuation.definition',
|
||||
'punctuation.separator',
|
||||
'meta.brace',
|
||||
'meta.bracket',
|
||||
],
|
||||
settings: { foreground: 'var(--color-diff-syntax-punctuation)' },
|
||||
},
|
||||
{ scope: ['entity.name.tag'], settings: { foreground: 'var(--color-diff-syntax-keyword)' } },
|
||||
{ scope: ['entity.other.attribute-name'], settings: { foreground: 'var(--color-diff-syntax-number)' } },
|
||||
{
|
||||
scope: ['source.json meta.structure.dictionary.json > string.quoted.json', 'support.type.property-name.json'],
|
||||
settings: { foreground: 'var(--color-diff-syntax-keyword)' },
|
||||
},
|
||||
{
|
||||
scope: ['support.type.property-name.css', 'support.type.vendored.property-name.css'],
|
||||
settings: { foreground: 'var(--color-diff-syntax-number)' },
|
||||
},
|
||||
{
|
||||
scope: ['markup.heading', 'entity.name.section'],
|
||||
settings: {
|
||||
foreground: 'var(--color-diff-syntax-function)',
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
},
|
||||
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
|
||||
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
|
||||
],
|
||||
}
|
||||
|
||||
const workspaceDiffLanguageLoaders: Record<string, () => Promise<LanguageRegistration[]>> = {
|
||||
bash: () => import('@shikijs/langs/bash').then((module) => module.default),
|
||||
c: () => import('@shikijs/langs/c').then((module) => module.default),
|
||||
cpp: () => import('@shikijs/langs/cpp').then((module) => module.default),
|
||||
csharp: () => import('@shikijs/langs/csharp').then((module) => module.default),
|
||||
css: () => import('@shikijs/langs/css').then((module) => module.default),
|
||||
dockerfile: () => import('@shikijs/langs/dockerfile').then((module) => module.default),
|
||||
go: () => import('@shikijs/langs/go').then((module) => module.default),
|
||||
graphql: () => import('@shikijs/langs/graphql').then((module) => module.default),
|
||||
html: () => import('@shikijs/langs/html').then((module) => module.default),
|
||||
java: () => import('@shikijs/langs/java').then((module) => module.default),
|
||||
javascript: () => import('@shikijs/langs/javascript').then((module) => module.default),
|
||||
json: () => import('@shikijs/langs/json').then((module) => module.default),
|
||||
jsonc: () => import('@shikijs/langs/jsonc').then((module) => module.default),
|
||||
jsx: () => import('@shikijs/langs/jsx').then((module) => module.default),
|
||||
kotlin: () => import('@shikijs/langs/kotlin').then((module) => module.default),
|
||||
less: () => import('@shikijs/langs/less').then((module) => module.default),
|
||||
lua: () => import('@shikijs/langs/lua').then((module) => module.default),
|
||||
makefile: () => import('@shikijs/langs/makefile').then((module) => module.default),
|
||||
markdown: () => import('@shikijs/langs/markdown').then((module) => module.default),
|
||||
php: () => import('@shikijs/langs/php').then((module) => module.default),
|
||||
prisma: () => import('@shikijs/langs/prisma').then((module) => module.default),
|
||||
python: () => import('@shikijs/langs/python').then((module) => module.default),
|
||||
ruby: () => import('@shikijs/langs/ruby').then((module) => module.default),
|
||||
rust: () => import('@shikijs/langs/rust').then((module) => module.default),
|
||||
sass: () => import('@shikijs/langs/sass').then((module) => module.default),
|
||||
scss: () => import('@shikijs/langs/scss').then((module) => module.default),
|
||||
sql: () => import('@shikijs/langs/sql').then((module) => module.default),
|
||||
svelte: () => import('@shikijs/langs/svelte').then((module) => module.default),
|
||||
swift: () => import('@shikijs/langs/swift').then((module) => module.default),
|
||||
toml: () => import('@shikijs/langs/toml').then((module) => module.default),
|
||||
tsx: () => import('@shikijs/langs/tsx').then((module) => module.default),
|
||||
typescript: () => import('@shikijs/langs/typescript').then((module) => module.default),
|
||||
vue: () => import('@shikijs/langs/vue').then((module) => module.default),
|
||||
xml: () => import('@shikijs/langs/xml').then((module) => module.default),
|
||||
yaml: () => import('@shikijs/langs/yaml').then((module) => module.default),
|
||||
}
|
||||
|
||||
const shikiLanguageAliases: Record<string, string> = {
|
||||
bash: 'bash',
|
||||
c: 'c',
|
||||
cc: 'cpp',
|
||||
cpp: 'cpp',
|
||||
cs: 'csharp',
|
||||
css: 'css',
|
||||
dockerfile: 'dockerfile',
|
||||
go: 'go',
|
||||
graphql: 'graphql',
|
||||
h: 'c',
|
||||
hpp: 'cpp',
|
||||
html: 'html',
|
||||
java: 'java',
|
||||
javascript: 'javascript',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
json: 'json',
|
||||
jsonc: 'jsonc',
|
||||
kotlin: 'kotlin',
|
||||
kt: 'kotlin',
|
||||
less: 'less',
|
||||
lua: 'lua',
|
||||
markdown: 'markdown',
|
||||
md: 'markdown',
|
||||
mjs: 'javascript',
|
||||
php: 'php',
|
||||
prisma: 'prisma',
|
||||
py: 'python',
|
||||
python: 'python',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
rust: 'rust',
|
||||
sass: 'sass',
|
||||
scss: 'scss',
|
||||
sh: 'bash',
|
||||
sql: 'sql',
|
||||
svelte: 'svelte',
|
||||
swift: 'swift',
|
||||
toml: 'toml',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
typescript: 'typescript',
|
||||
vue: 'vue',
|
||||
xml: 'xml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash',
|
||||
}
|
||||
|
||||
let workspaceDiffHighlighterPromise: Promise<HighlighterCore> | null = null
|
||||
const workspaceDiffLanguagePromises = new Map<string, Promise<void>>()
|
||||
|
||||
function getWorkspaceDiffHighlighter() {
|
||||
workspaceDiffHighlighterPromise ??= createHighlighterCore({
|
||||
themes: [workspaceDiffShikiTheme],
|
||||
langs: [],
|
||||
engine: createOnigurumaEngine(import('shiki/wasm')),
|
||||
})
|
||||
return workspaceDiffHighlighterPromise
|
||||
}
|
||||
|
||||
async function ensureWorkspaceDiffLanguage(highlighter: HighlighterCore, language: string) {
|
||||
if (language === 'text' || highlighter.getLoadedLanguages().includes(language)) return
|
||||
const loader = workspaceDiffLanguageLoaders[language]
|
||||
if (!loader) return
|
||||
|
||||
let loading = workspaceDiffLanguagePromises.get(language)
|
||||
if (!loading) {
|
||||
loading = loader().then(async (registrations) => {
|
||||
await highlighter.loadLanguage(...registrations)
|
||||
})
|
||||
workspaceDiffLanguagePromises.set(language, loading)
|
||||
}
|
||||
await loading
|
||||
}
|
||||
|
||||
function basename(path: string) {
|
||||
return path.split('/').pop()?.toLowerCase() ?? path.toLowerCase()
|
||||
}
|
||||
|
||||
export function getWorkspaceDiffShikiLanguage(path: string) {
|
||||
const name = basename(path)
|
||||
if (name === 'dockerfile') return 'dockerfile'
|
||||
if (name === 'makefile') return 'makefile'
|
||||
if (name === '.gitignore') return 'text'
|
||||
const extension = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : ''
|
||||
return shikiLanguageAliases[extension] ?? 'text'
|
||||
}
|
||||
|
||||
function tokenizeWords(value: string): WordSegment[] {
|
||||
const segments: WordSegment[] = []
|
||||
const pattern = /\s+|[\p{L}\p{N}_$]+|[^\s\p{L}\p{N}_$]+/gu
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(value))) {
|
||||
segments.push({
|
||||
text: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
})
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function mergeRanges(value: string, ranges: WorkspaceDiffWordRange[]) {
|
||||
const sorted = [...ranges].sort((left, right) => left.start - right.start)
|
||||
const merged: WorkspaceDiffWordRange[] = []
|
||||
for (const range of sorted) {
|
||||
const previous = merged.at(-1)
|
||||
if (previous && /^\s*$/.test(value.slice(previous.end, range.start))) {
|
||||
previous.end = range.end
|
||||
} else {
|
||||
merged.push({ ...range })
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function diffWordRanges(oldValue: string, newValue: string) {
|
||||
if (
|
||||
oldValue.length > WORKSPACE_DIFF_TOKENIZE_MAX_LINE_LENGTH
|
||||
|| newValue.length > WORKSPACE_DIFF_TOKENIZE_MAX_LINE_LENGTH
|
||||
) return null
|
||||
|
||||
const oldSegments = tokenizeWords(oldValue)
|
||||
const newSegments = tokenizeWords(newValue)
|
||||
if (
|
||||
oldSegments.length > WORKSPACE_DIFF_WORD_MAX_SEGMENTS
|
||||
|| newSegments.length > WORKSPACE_DIFF_WORD_MAX_SEGMENTS
|
||||
) return null
|
||||
|
||||
const matrix = Array.from(
|
||||
{ length: oldSegments.length + 1 },
|
||||
() => new Uint16Array(newSegments.length + 1),
|
||||
)
|
||||
for (let oldIndex = oldSegments.length - 1; oldIndex >= 0; oldIndex -= 1) {
|
||||
for (let newIndex = newSegments.length - 1; newIndex >= 0; newIndex -= 1) {
|
||||
matrix[oldIndex]![newIndex] = oldSegments[oldIndex]!.text === newSegments[newIndex]!.text
|
||||
? matrix[oldIndex + 1]![newIndex + 1]! + 1
|
||||
: Math.max(matrix[oldIndex + 1]![newIndex]!, matrix[oldIndex]![newIndex + 1]!)
|
||||
}
|
||||
}
|
||||
|
||||
const matchedOld = new Set<number>()
|
||||
const matchedNew = new Set<number>()
|
||||
let oldIndex = 0
|
||||
let newIndex = 0
|
||||
while (oldIndex < oldSegments.length && newIndex < newSegments.length) {
|
||||
if (oldSegments[oldIndex]!.text === newSegments[newIndex]!.text) {
|
||||
matchedOld.add(oldIndex)
|
||||
matchedNew.add(newIndex)
|
||||
oldIndex += 1
|
||||
newIndex += 1
|
||||
} else if (matrix[oldIndex + 1]![newIndex]! >= matrix[oldIndex]![newIndex + 1]!) {
|
||||
oldIndex += 1
|
||||
} else {
|
||||
newIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
const oldMeaningfulCount = oldSegments.filter((segment) => !/^\s+$/.test(segment.text)).length
|
||||
const newMeaningfulCount = newSegments.filter((segment) => !/^\s+$/.test(segment.text)).length
|
||||
const matchedMeaningfulCount = [...matchedOld]
|
||||
.filter((index) => !/^\s+$/.test(oldSegments[index]!.text))
|
||||
.length
|
||||
const similarity = oldMeaningfulCount + newMeaningfulCount === 0
|
||||
? 1
|
||||
: (2 * matchedMeaningfulCount) / (oldMeaningfulCount + newMeaningfulCount)
|
||||
if (similarity < WORKSPACE_DIFF_WORD_MIN_SIMILARITY) return null
|
||||
|
||||
const toRanges = (value: string, segments: WordSegment[], matched: Set<number>) => mergeRanges(
|
||||
value,
|
||||
segments.flatMap((segment, index) => (
|
||||
matched.has(index) || /^\s+$/.test(segment.text)
|
||||
? []
|
||||
: [{ start: segment.start, end: segment.end }]
|
||||
)),
|
||||
)
|
||||
|
||||
return {
|
||||
oldRanges: toRanges(oldValue, oldSegments, matchedOld),
|
||||
newRanges: toRanges(newValue, newSegments, matchedNew),
|
||||
}
|
||||
}
|
||||
|
||||
function flushChangeGroup(
|
||||
deletions: WorkspaceDiffRow[],
|
||||
additions: WorkspaceDiffRow[],
|
||||
rangesByRowId: Record<string, WorkspaceDiffWordRange[]>,
|
||||
) {
|
||||
if (deletions.length !== additions.length) {
|
||||
deletions.length = 0
|
||||
additions.length = 0
|
||||
return
|
||||
}
|
||||
const pairCount = Math.min(deletions.length, additions.length)
|
||||
for (let index = 0; index < pairCount; index += 1) {
|
||||
const deletion = deletions[index]!
|
||||
const addition = additions[index]!
|
||||
const ranges = diffWordRanges(deletion.text, addition.text)
|
||||
if (!ranges) continue
|
||||
if (ranges.oldRanges.length > 0) rangesByRowId[deletion.id] = ranges.oldRanges
|
||||
if (ranges.newRanges.length > 0) rangesByRowId[addition.id] = ranges.newRanges
|
||||
}
|
||||
deletions.length = 0
|
||||
additions.length = 0
|
||||
}
|
||||
|
||||
export function buildWorkspaceDiffWordRanges(files: WorkspaceDiffFile[]) {
|
||||
const rangesByRowId: Record<string, WorkspaceDiffWordRange[]> = {}
|
||||
for (const file of files) {
|
||||
const deletions: WorkspaceDiffRow[] = []
|
||||
const additions: WorkspaceDiffRow[] = []
|
||||
let activeHunkId: string | null = null
|
||||
|
||||
for (const row of file.rows) {
|
||||
const isChange = row.kind === 'deletion' || row.kind === 'addition'
|
||||
if (!isChange || row.hunkId !== activeHunkId) {
|
||||
flushChangeGroup(deletions, additions, rangesByRowId)
|
||||
activeHunkId = isChange ? row.hunkId : null
|
||||
}
|
||||
if (row.kind === 'deletion') deletions.push(row)
|
||||
else if (row.kind === 'addition') additions.push(row)
|
||||
}
|
||||
flushChangeGroup(deletions, additions, rangesByRowId)
|
||||
}
|
||||
return rangesByRowId
|
||||
}
|
||||
|
||||
function getHighlightDocuments(file: WorkspaceDiffFile) {
|
||||
const documents: Array<{ rows: WorkspaceDiffRow[]; path: string }> = []
|
||||
const hunkIds = [...new Set(file.rows.flatMap((row) => row.hunkId ? [row.hunkId] : []))]
|
||||
const path = file.newPath ?? file.oldPath ?? ''
|
||||
for (const hunkId of hunkIds) {
|
||||
const hunkRows = file.rows.filter((row) => row.hunkId === hunkId && row.selectable)
|
||||
const oldRows = hunkRows.filter((row) => row.kind === 'context' || row.kind === 'deletion')
|
||||
const newRows = hunkRows.filter((row) => row.kind === 'context' || row.kind === 'addition')
|
||||
if (oldRows.length > 0) documents.push({ rows: oldRows, path: file.oldPath ?? path })
|
||||
if (newRows.length > 0) documents.push({ rows: newRows, path: file.newPath ?? path })
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
export async function highlightWorkspaceDiff({
|
||||
files,
|
||||
path,
|
||||
}: {
|
||||
files: WorkspaceDiffFile[]
|
||||
path: string
|
||||
}): Promise<WorkspaceDiffHighlightResult> {
|
||||
const tokensByRowId: Record<string, WorkspaceDiffHighlightToken[]> = {}
|
||||
const wordRangesByRowId = buildWorkspaceDiffWordRanges(files)
|
||||
|
||||
try {
|
||||
const highlighter = await getWorkspaceDiffHighlighter()
|
||||
for (const file of files) {
|
||||
for (const document of getHighlightDocuments(file)) {
|
||||
const language = getWorkspaceDiffShikiLanguage(document.path || path)
|
||||
await ensureWorkspaceDiffLanguage(highlighter, language)
|
||||
const result = highlighter.codeToTokens(document.rows.map((row) => row.text).join('\n'), {
|
||||
lang: workspaceDiffLanguageLoaders[language] ? language : 'text',
|
||||
theme: workspaceDiffShikiTheme,
|
||||
tokenizeMaxLineLength: WORKSPACE_DIFF_TOKENIZE_MAX_LINE_LENGTH,
|
||||
})
|
||||
document.rows.forEach((row, index) => {
|
||||
tokensByRowId[row.id] = (result.tokens[index] ?? []).map((token) => ({
|
||||
content: token.content,
|
||||
color: token.color,
|
||||
fontStyle: token.fontStyle,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
return { engine: 'shiki', tokensByRowId, wordRangesByRowId }
|
||||
} catch {
|
||||
return { engine: 'plain', tokensByRowId: {}, wordRangesByRowId }
|
||||
}
|
||||
}
|
||||
@ -546,6 +546,19 @@
|
||||
--color-code-inserted: #1A7F37;
|
||||
--color-code-deleted: #CF222E;
|
||||
|
||||
--color-diff-syntax-foreground: #0D0D0D;
|
||||
--color-diff-syntax-comment: #666666;
|
||||
--color-diff-syntax-string: #008809;
|
||||
--color-diff-syntax-number: #0071EA;
|
||||
--color-diff-syntax-regexp: #0071EA;
|
||||
--color-diff-syntax-keyword: #D53538;
|
||||
--color-diff-syntax-variable: #BD5800;
|
||||
--color-diff-syntax-parameter: #666666;
|
||||
--color-diff-syntax-property: #BD5800;
|
||||
--color-diff-syntax-function: #751ED9;
|
||||
--color-diff-syntax-type: #751ED9;
|
||||
--color-diff-syntax-punctuation: #666666;
|
||||
|
||||
--color-diff-added-bg: #E8F5E2;
|
||||
--color-diff-added-word: #B8E4A8;
|
||||
--color-diff-added-gutter: #D4EDCA;
|
||||
@ -743,6 +756,19 @@
|
||||
--color-code-inserted: #067647;
|
||||
--color-code-deleted: #B42318;
|
||||
|
||||
--color-diff-syntax-foreground: #0D0D0D;
|
||||
--color-diff-syntax-comment: #666666;
|
||||
--color-diff-syntax-string: #008809;
|
||||
--color-diff-syntax-number: #0071EA;
|
||||
--color-diff-syntax-regexp: #0071EA;
|
||||
--color-diff-syntax-keyword: #D53538;
|
||||
--color-diff-syntax-variable: #BD5800;
|
||||
--color-diff-syntax-parameter: #666666;
|
||||
--color-diff-syntax-property: #BD5800;
|
||||
--color-diff-syntax-function: #751ED9;
|
||||
--color-diff-syntax-type: #751ED9;
|
||||
--color-diff-syntax-punctuation: #666666;
|
||||
|
||||
--color-diff-added-bg: #ECFDF3;
|
||||
--color-diff-added-word: #ABEFC6;
|
||||
--color-diff-added-gutter: #D1FADF;
|
||||
@ -932,6 +958,19 @@
|
||||
--color-code-inserted: #8EEA9A;
|
||||
--color-code-deleted: #FFB59F;
|
||||
|
||||
--color-diff-syntax-foreground: #FCFCFC;
|
||||
--color-diff-syntax-comment: #999999;
|
||||
--color-diff-syntax-string: #85DF7B;
|
||||
--color-diff-syntax-number: #6DCBF4;
|
||||
--color-diff-syntax-regexp: #3D8DFF;
|
||||
--color-diff-syntax-keyword: #F67576;
|
||||
--color-diff-syntax-variable: #FA994C;
|
||||
--color-diff-syntax-parameter: #999999;
|
||||
--color-diff-syntax-property: #FA994C;
|
||||
--color-diff-syntax-function: #B06DFF;
|
||||
--color-diff-syntax-type: #B06DFF;
|
||||
--color-diff-syntax-punctuation: #999999;
|
||||
|
||||
--color-diff-added-bg: rgba(126, 219, 139, 0.12);
|
||||
--color-diff-added-word: rgba(126, 219, 139, 0.22);
|
||||
--color-diff-added-gutter: rgba(126, 219, 139, 0.18);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user