fix(desktop): match Codex diff syntax rendering #1004

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 17:28:35 +08:00
parent cc1eb4ee3a
commit c5ad527034
9 changed files with 1345 additions and 23 deletions

View File

@ -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')
})

View File

@ -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>

View File

@ -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' })
})
})

View File

@ -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),
})
}
}

View File

@ -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()
})
})

View File

@ -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
}

View File

@ -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({})
})
})

View 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 }
}
}

View File

@ -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);