diff --git a/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx b/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx index 98e710c7..8ed8a346 100644 --- a/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx +++ b/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx @@ -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() +vi.mock('./workspaceDiffHighlightRuntime', () => { return { - ...actual, - Highlight: (props: ComponentProps) => { - highlightRenderSpy() - return - }, + 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> = {} + 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() - 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() + 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() + + 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() + + 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() @@ -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() 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') }) diff --git a/desktop/src/components/workspace/WorkspaceDiffSurface.tsx b/desktop/src/components/workspace/WorkspaceDiffSurface.tsx index 1de50ce9..5b9cb3c8 100644 --- a/desktop/src/components/workspace/WorkspaceDiffSurface.tsx +++ b/desktop/src/components/workspace/WorkspaceDiffSurface.tsx @@ -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 ( + + {token.content.slice(start - tokenStart, end - tokenStart)} + + ) + }) + })} + + ) +}) + 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(() => rows.find((row) => row.selectable)?.id ?? null) const buttonRefs = useRef(new Map()) const editorRef = useRef(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({
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({ > {row.prefix || ' '} - {row.selectable && row.text && !usePlainLargePreview - ? + {row.selectable && row.text && highlightResult.tokensByRowId[row.id] + ? ( + + ) : row.text || ' '} diff --git a/desktop/src/components/workspace/workspaceDiffHighlight.worker.test.ts b/desktop/src/components/workspace/workspaceDiffHighlight.worker.test.ts new file mode 100644 index 00000000..71224cf9 --- /dev/null +++ b/desktop/src/components/workspace/workspaceDiffHighlight.worker.test.ts @@ -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 + 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' }) + }) +}) diff --git a/desktop/src/components/workspace/workspaceDiffHighlight.worker.ts b/desktop/src/components/workspace/workspaceDiffHighlight.worker.ts new file mode 100644 index 00000000..b9ce361d --- /dev/null +++ b/desktop/src/components/workspace/workspaceDiffHighlight.worker.ts @@ -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) => { + 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), + }) + } +} diff --git a/desktop/src/components/workspace/workspaceDiffHighlightRuntime.test.ts b/desktop/src/components/workspace/workspaceDiffHighlightRuntime.test.ts new file mode 100644 index 00000000..b5832acd --- /dev/null +++ b/desktop/src/components/workspace/workspaceDiffHighlightRuntime.test.ts @@ -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() + }) +}) diff --git a/desktop/src/components/workspace/workspaceDiffHighlightRuntime.ts b/desktop/src/components/workspace/workspaceDiffHighlightRuntime.ts new file mode 100644 index 00000000..c6f1feb3 --- /dev/null +++ b/desktop/src/components/workspace/workspaceDiffHighlightRuntime.ts @@ -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 +} + +interface CachedHighlight { + result: WorkspaceDiffHighlightResult + weight: number +} + +const cache = new Map() +const pendingByCacheKey = new Map>() +const pendingById = new Map() +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((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 +} diff --git a/desktop/src/components/workspace/workspaceDiffHighlighter.test.ts b/desktop/src/components/workspace/workspaceDiffHighlighter.test.ts new file mode 100644 index 00000000..340106c9 --- /dev/null +++ b/desktop/src/components/workspace/workspaceDiffHighlighter.test.ts @@ -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({}) + }) +}) diff --git a/desktop/src/components/workspace/workspaceDiffHighlighter.ts b/desktop/src/components/workspace/workspaceDiffHighlighter.ts new file mode 100644 index 00000000..ac685630 --- /dev/null +++ b/desktop/src/components/workspace/workspaceDiffHighlighter.ts @@ -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 + wordRangesByRowId: Record +} + +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 Promise> = { + 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 = { + 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 | null = null +const workspaceDiffLanguagePromises = new Map>() + +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() + const matchedNew = new Set() + 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) => 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, +) { + 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 = {} + 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 { + const tokensByRowId: Record = {} + 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 } + } +} diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 78d15bd5..81254650 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -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);