= {
- text: 'text',
- typescript: 'typescript',
- ts: 'typescript',
- tsx: 'tsx',
- javascript: 'javascript',
- js: 'javascript',
- jsx: 'jsx',
- markdown: 'markdown',
- md: 'markdown',
- html: 'markup',
- xml: 'markup',
- shell: 'bash',
- sh: 'bash',
- zsh: 'bash',
- diff: 'diff',
- }
- return map[lower] ?? lower
-}
-
-export function getLanguageFromPath(path: string) {
- return normalizePrismLanguage(getFileExtension(path) || 'text')
-}
-
-export function InlineHighlightedCode({
- value,
- language,
-}: {
- value: string
- language: string
-}) {
- return (
-
- {({ tokens, getTokenProps }) => (
- <>
- {(tokens[0] ?? []).map((token, tokenIndex) => {
- const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
- return
- })}
- >
- )}
-
- )
-}
-
-export function WorkspaceDiffSurface({
- value,
- path,
- className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
- lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
-}: {
- value: string
- path: string
- className?: string
- lineLimit?: number
-}) {
- const t = useTranslation()
- const [showAllLines, setShowAllLines] = useState(false)
- const lines = value.split('\n')
- const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit)
- const language = getLanguageFromPath(path)
- const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
-
- useEffect(() => {
- setShowAllLines(false)
- }, [path, value])
-
- return (
-
-
-
- {visibleLines.map((line, index) => {
- const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
- const isHunk = line.startsWith('@@')
- const isAdded = line.startsWith('+') && !line.startsWith('+++')
- const isRemoved = line.startsWith('-') && !line.startsWith('---')
- const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
- const code = isCodeLine ? line.slice(1) : line
- const prefix = isCodeLine ? line[0] : ' '
-
- return (
-
-
- {index + 1}
-
-
- {prefix}
-
-
- {isCodeLine && !usePlainLargePreview ? (
- code ? : ' '
- ) : (
- code || ' '
- )}
-
-
- )
- })}
-
- {lines.length > lineLimit && (
-
-
- {showAllLines
- ? t('workspace.previewAllLines', { total: lines.length })
- : t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
-
-
-
- )}
-
-
- )
-}
+export {
+ getFileExtension,
+ getLanguageFromPath,
+ InlineHighlightedCode,
+ normalizePrismLanguage,
+ WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
+ WORKSPACE_PREVIEW_LINE_LIMIT,
+ WorkspaceDiffSurface,
+ workspacePrismTheme,
+} from './WorkspaceDiffSurface'
+export type {
+ WorkspaceDiffCommentSelection,
+ WorkspaceDiffSurfaceProps,
+} from './WorkspaceDiffSurface'
diff --git a/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx b/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx
new file mode 100644
index 00000000..8a12c313
--- /dev/null
+++ b/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx
@@ -0,0 +1,353 @@
+import '@testing-library/jest-dom/vitest'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import type { ComponentProps } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useSettingsStore } from '../../stores/settingsStore'
+import { WorkspaceDiffSurface } from './WorkspaceDiffSurface'
+import {
+ WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
+ WORKSPACE_PREVIEW_LINE_LIMIT,
+ WorkspaceDiffSurface as ExportedWorkspaceDiffSurface,
+} from './WorkspaceCodeSurface'
+
+const highlightRenderSpy = vi.hoisted(() => vi.fn())
+
+vi.mock('prism-react-renderer', async (importOriginal) => {
+ const actual = await importOriginal
()
+ return {
+ ...actual,
+ Highlight: (props: ComponentProps) => {
+ highlightRenderSpy()
+ return
+ },
+ }
+})
+
+const diff = [
+ 'diff --git a/src/a.ts b/src/a.ts',
+ '--- a/src/a.ts',
+ '+++ b/src/a.ts',
+ '@@ -10,2 +10,3 @@',
+ ' const a = 1',
+ '-const b = 2',
+ '+const b = 3',
+ '+const c = 4',
+ '@@ -20 +21 @@',
+ '-old tail',
+ '+new tail',
+].join('\n')
+
+function getCodeRow(text: string) {
+ const row = document.querySelector(`[data-row-text="${text}"]`)
+ expect(row).not.toBeNull()
+ return row!
+}
+
+describe('WorkspaceDiffSurface', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ locale: 'en' })
+ highlightRenderSpy.mockClear()
+ })
+
+ it('submits a forward range with its source coordinates and quote', () => {
+ const onAddComment = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }), { shiftKey: true })
+ expect(screen.getByText('new L11-L12')).toBeInTheDocument()
+ const rangeEndRow = getCodeRow('const c = 4').closest('[data-diff-row-id]')
+ const editorContainer = screen.getByRole('textbox', { name: 'Review comment' }).closest('[data-diff-editor]')
+ expect(rangeEndRow?.nextElementSibling).toBe(editorContainer)
+ expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
+ expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
+
+ const editor = screen.getByRole('textbox', { name: 'Review comment' })
+ fireEvent.change(editor, { target: { value: 'Use a shared helper' } })
+ fireEvent.keyDown(editor, { key: 'Enter', metaKey: true })
+
+ expect(onAddComment).toHaveBeenCalledWith(expect.objectContaining({
+ side: 'new',
+ lineStart: 11,
+ lineEnd: 12,
+ quote: 'const b = 3\nconst c = 4',
+ hunkId: 'file-0-hunk-0',
+ }), 'Use a shared helper')
+ })
+
+ it('normalizes reverse Shift selection', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }), { shiftKey: true })
+
+ expect(screen.getByText('new L11-L12')).toBeInTheDocument()
+ expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
+ expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('does not submit an empty review comment', () => {
+ const onAddComment = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Enter', ctrlKey: true })
+
+ expect(onAddComment).not.toHaveBeenCalled()
+ expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument()
+ })
+
+ it('closes on Escape and restores focus to the anchor gutter button', () => {
+ render()
+ const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
+
+ fireEvent.click(anchor)
+ fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Escape' })
+
+ expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
+ expect(anchor).toHaveFocus()
+ })
+
+ it('resets an incompatible Shift range and announces why', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts old line 20' }), { shiftKey: true })
+
+ expect(screen.getByText('Selection reset: choose lines from the same side and hunk.')).toBeInTheDocument()
+ expect(screen.getByText('old L20')).toBeInTheDocument()
+ })
+
+ it('uses one roving tab stop and supports Arrow, Home, End, and activation keys', () => {
+ render()
+ const buttons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
+ const firstButton = buttons[0]!
+ const secondButton = buttons[1]!
+
+ expect(buttons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
+ act(() => firstButton.focus())
+ fireEvent.keyDown(firstButton, { key: 'ArrowDown' })
+ expect(secondButton).toHaveFocus()
+ expect(secondButton).toHaveAttribute('tabindex', '0')
+
+ fireEvent.keyDown(secondButton, { key: 'End' })
+ expect(buttons.at(-1)).toHaveFocus()
+ fireEvent.keyDown(buttons.at(-1)!, { key: 'Home' })
+ expect(firstButton).toHaveFocus()
+ fireEvent.keyDown(firstButton, { key: ' ' })
+ expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus()
+ })
+
+ it('keeps Shift+Home selection inside the current side and hunk', () => {
+ render()
+ const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
+ const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
+
+ act(() => line11.focus())
+ fireEvent.keyDown(line11, { key: 'Home', shiftKey: true })
+
+ expect(line10).toHaveFocus()
+ expect(screen.getByText('new L10-L11')).toBeInTheDocument()
+ expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true')
+ expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('extends the range with Shift+Arrow and returns focus after submit', () => {
+ const onAddComment = vi.fn()
+ render()
+ const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
+
+ act(() => anchor.focus())
+ fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true })
+ expect(screen.getByText('new L11-L12')).toBeInTheDocument()
+
+ const editor = screen.getByRole('textbox', { name: 'Review comment' })
+ fireEvent.change(editor, { target: { value: 'Keep this focused' } })
+ fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true })
+ expect(onAddComment).toHaveBeenCalledOnce()
+ expect(anchor).toHaveFocus()
+ })
+
+ it('skips incompatible rows when extending with Shift+Arrow', () => {
+ render()
+ const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
+
+ act(() => anchor.focus())
+ fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true })
+
+ expect(screen.getByText('new L10-L11')).toBeInTheDocument()
+ expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true')
+ expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('keeps gutter focus for repeatable Shift+Arrow extension and shrinking', () => {
+ render()
+ const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
+ const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
+ const line12 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
+
+ act(() => line10.focus())
+ fireEvent.keyDown(line10, { key: 'ArrowDown', shiftKey: true })
+ expect(line11).toHaveFocus()
+ fireEvent.keyDown(line11, { key: 'ArrowDown', shiftKey: true })
+ expect(line12).toHaveFocus()
+ expect(screen.getByText('new L10-L12')).toBeInTheDocument()
+
+ fireEvent.keyDown(line12, { key: 'ArrowUp', shiftKey: true })
+ expect(line11).toHaveFocus()
+ expect(screen.getByText('new L10-L11')).toBeInTheDocument()
+ })
+
+ it('keeps roving navigation on mounted rows when the preview is truncated', () => {
+ render()
+ const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
+ const lastVisibleButton = visibleButtons.at(-1)!
+
+ act(() => lastVisibleButton.focus())
+ fireEvent.keyDown(lastVisibleButton, { key: 'ArrowDown' })
+
+ expect(lastVisibleButton).toHaveFocus()
+ expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
+ expect(screen.getByText('Showing first 5 of 11 loaded lines.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Show all loaded lines' })).toBeInTheDocument()
+ })
+
+ it('invalidates a hidden selection on collapse while preserving its draft and visible roving target', () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
+ target: { value: 'Keep this collapsed draft' },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse preview' }))
+
+ expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Select visible lines again')
+ const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
+ expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
+ expect(visibleButtons[0]).toHaveFocus()
+
+ fireEvent.click(visibleButtons[0]!)
+ 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', () => {
+ const additions = Array.from(
+ { length: WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + 1 },
+ (_, index) => `+const value${index} = ${index}`,
+ )
+ const largeDiff = [
+ 'diff --git a/src/large.ts b/src/large.ts',
+ '--- a/src/large.ts',
+ '+++ b/src/large.ts',
+ `@@ -0,0 +1,${additions.length} @@`,
+ ...additions,
+ ].join('\n')
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
+
+ expect(document.querySelector('.token')).not.toBeInTheDocument()
+ expect(getCodeRow('const value5000 = 5000')).toHaveTextContent('const value5000 = 5000')
+ })
+
+ it('renders parsed file headers and keeps multiple files visually separated', () => {
+ const multiFileDiff = [
+ diff,
+ 'diff --git a/src/b.ts b/src/b.ts',
+ '--- a/src/b.ts',
+ '+++ b/src/b.ts',
+ '@@ -1 +1 @@',
+ '-export const before = true',
+ '+export const after = true',
+ ].join('\n')
+
+ render()
+
+ const headers = screen.getAllByTestId('workspace-diff-file-header')
+ expect(headers).toHaveLength(2)
+ expect(headers[0]).toHaveTextContent('diff --git a/src/a.ts b/src/a.ts')
+ 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', () => {
+ render()
+
+ const keyword = screen.getAllByText('const').find((element) => element.classList.contains('keyword'))
+ expect(keyword).toHaveClass('token', 'keyword')
+ expect(document.querySelectorAll('[data-row-text="const b = 3"]')).toHaveLength(1)
+ })
+
+ it('renders the complete review flow in Chinese', () => {
+ useSettingsStore.setState({ locale: 'zh' })
+ render()
+
+ const gutter = screen.getByRole('button', { name: '评论 src/a.ts 的新侧第 11 行' })
+ fireEvent.click(gutter)
+
+ expect(screen.getByRole('textbox', { name: '评审评论' })).toHaveFocus()
+ expect(screen.getByText('新 L11')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '提交评审评论' })).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: '评论 src/a.ts 的旧侧第 20 行' }), { shiftKey: true })
+ expect(screen.getByRole('status')).toHaveTextContent('只能选择同一侧、同一变更块中的行')
+ })
+
+ it('does not rerun Prism highlighting for each controlled draft change', () => {
+ const additions = Array.from(
+ { length: WORKSPACE_PREVIEW_LINE_LIMIT - 4 },
+ (_, index) => `+const value${index + 1} = ${index + 1}`,
+ )
+ const nearLimitDiff = [
+ 'diff --git a/src/near-limit.ts b/src/near-limit.ts',
+ '--- a/src/near-limit.ts',
+ '+++ b/src/near-limit.ts',
+ `@@ -0,0 +1,${additions.length} @@`,
+ ...additions,
+ ].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 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(editor).toHaveValue('abc')
+ })
+
+ it('preserves draft text but invalidates its selection when the diff changes', () => {
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
+ target: { value: 'Draft survives refresh' },
+ })
+ rerender()
+
+ expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
+ expect(screen.getByText('Diff changed. Select lines again to submit this comment.')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Draft survives refresh')
+ })
+
+ it('resets the editor and draft when the path changes', () => {
+ const { rerender } = render()
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
+ target: { value: 'Discard on another file' },
+ })
+
+ rerender()
+
+ expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Comment on src/b.ts new line 11' }))
+ expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('')
+ })
+})
diff --git a/desktop/src/components/workspace/WorkspaceDiffSurface.tsx b/desktop/src/components/workspace/WorkspaceDiffSurface.tsx
new file mode 100644
index 00000000..808e0d66
--- /dev/null
+++ b/desktop/src/components/workspace/WorkspaceDiffSurface.tsx
@@ -0,0 +1,556 @@
+import {
+ Fragment,
+ memo,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type MouseEvent,
+} from 'react'
+import { CornerDownLeft, MessageSquare, Plus } from 'lucide-react'
+import { Highlight, type PrismTheme } from 'prism-react-renderer'
+import { useTranslation } from '../../i18n'
+import {
+ getCompatibleDiffRange,
+ parseWorkspaceDiff,
+ type WorkspaceDiffRow,
+ type WorkspaceDiffSelection,
+} from './workspaceDiffModel'
+
+export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
+export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
+
+export const workspacePrismTheme: PrismTheme = {
+ plain: {
+ color: 'var(--color-code-fg)',
+ backgroundColor: 'transparent',
+ },
+ styles: [
+ { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
+ { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
+ { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
+ { types: ['function'], style: { color: 'var(--color-code-function)' } },
+ { types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
+ { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
+ { types: ['operator'], style: { color: 'var(--color-code-fg)' } },
+ { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
+ { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
+ { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
+ { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
+ { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
+ { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
+ ],
+}
+
+export function getFileExtension(name: string) {
+ const cleanName = name.split('/').pop() ?? name
+ const lastDot = cleanName.lastIndexOf('.')
+ if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
+ return cleanName.slice(lastDot + 1).toLowerCase()
+}
+
+export function normalizePrismLanguage(language: string) {
+ const lower = language.toLowerCase()
+ const map: Record = {
+ text: 'text',
+ typescript: 'typescript',
+ ts: 'typescript',
+ tsx: 'tsx',
+ javascript: 'javascript',
+ js: 'javascript',
+ jsx: 'jsx',
+ markdown: 'markdown',
+ md: 'markdown',
+ html: 'markup',
+ xml: 'markup',
+ shell: 'bash',
+ sh: 'bash',
+ zsh: 'bash',
+ diff: 'diff',
+ }
+ return map[lower] ?? lower
+}
+
+export function getLanguageFromPath(path: string) {
+ return normalizePrismLanguage(getFileExtension(path) || 'text')
+}
+
+export const InlineHighlightedCode = memo(function InlineHighlightedCode({
+ value,
+ language,
+}: {
+ value: string
+ language: string
+}) {
+ return (
+
+ {({ tokens, getTokenProps }) => (
+ <>
+ {(tokens[0] ?? []).map((token, tokenIndex) => {
+ const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
+ return
+ })}
+ >
+ )}
+
+ )
+})
+
+export interface WorkspaceDiffCommentSelection {
+ side: 'old' | 'new'
+ lineStart: number
+ lineEnd: number
+ quote: string
+ hunkId: string
+}
+
+export interface WorkspaceDiffSurfaceProps {
+ value: string
+ path: string
+ className?: string
+ lineLimit?: number
+ onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
+}
+
+interface ReviewState {
+ anchorId: string | null
+ focusId: string | null
+ selection: WorkspaceDiffSelection | null
+ draft: string
+}
+
+type ReviewStatus = 'selectionReset' | 'diffChanged' | 'collapsedSelection' | null
+
+const emptyReviewState: ReviewState = {
+ anchorId: null,
+ focusId: null,
+ selection: null,
+ draft: '',
+}
+
+function rowTone(row: WorkspaceDiffRow) {
+ if (row.kind === 'addition') return 'bg-[var(--color-diff-added-bg)]'
+ if (row.kind === 'deletion') return 'bg-[var(--color-diff-removed-bg)]'
+ if (row.kind === 'hunk') return 'bg-[var(--color-diff-highlight-bg)]'
+ return 'hover:bg-[var(--color-surface-hover)]'
+}
+
+function prefixTone(row: WorkspaceDiffRow) {
+ if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]'
+ if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]'
+ return 'text-[var(--color-text-tertiary)]'
+}
+
+function codeTone(row: WorkspaceDiffRow) {
+ if (row.kind === 'metadata') return 'font-semibold text-[var(--color-text-secondary)]'
+ if (row.kind === 'hunk') return 'font-semibold text-[var(--color-warning)]'
+ return ''
+}
+
+export function WorkspaceDiffSurface({
+ value,
+ path,
+ className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
+ lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
+ onAddComment,
+}: WorkspaceDiffSurfaceProps) {
+ const t = useTranslation()
+ const files = useMemo(() => parseWorkspaceDiff(value), [value])
+ const rows = useMemo(() => files.flatMap((file) => file.rows), [files])
+ const displayItemIds = useMemo(
+ () => files.flatMap((file) => [`${file.id}-header`, ...file.rows.map((row) => row.id)]),
+ [files],
+ )
+ const [review, setReview] = useState(emptyReviewState)
+ const [status, setStatus] = useState(null)
+ const [showAllRows, setShowAllRows] = useState(false)
+ const visibleItemIds = useMemo(
+ () => new Set(showAllRows ? displayItemIds : displayItemIds.slice(0, lineLimit)),
+ [displayItemIds, lineLimit, showAllRows],
+ )
+ 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 [rovingId, setRovingId] = useState(() => rows.find((row) => row.selectable)?.id ?? null)
+ const buttonRefs = useRef(new Map())
+ const editorRef = useRef(null)
+ const shouldFocusEditor = useRef(false)
+ const pendingRovingFocus = useRef(null)
+ const previousPath = useRef(path)
+ const previousValue = useRef(value)
+ const selectedIds = new Set(review.selection?.rowIds ?? [])
+ const sideLabel = (side: 'old' | 'new') => t(`workspace.diffReview.side.${side}`)
+
+ useEffect(() => {
+ const pathChanged = previousPath.current !== path
+ const valueChanged = previousValue.current !== value
+ previousPath.current = path
+ previousValue.current = value
+
+ if (pathChanged) {
+ setReview(emptyReviewState)
+ setStatus(null)
+ setShowAllRows(false)
+ setRovingId(selectableRows[0]?.id ?? null)
+ return
+ }
+
+ if (valueChanged) {
+ setReview((current) => ({
+ ...current,
+ anchorId: null,
+ focusId: null,
+ selection: null,
+ }))
+ setStatus(review.draft ? 'diffChanged' : null)
+ setRovingId(selectableRows[0]?.id ?? null)
+ }
+ }, [path, review.draft, selectableRows, value])
+
+ useEffect(() => {
+ if (!rovingId || !selectableRows.some((row) => row.id === rovingId)) {
+ setRovingId(selectableRows[0]?.id ?? null)
+ }
+ const pendingId = pendingRovingFocus.current
+ if (pendingId && selectableRows.some((row) => row.id === pendingId)) {
+ pendingRovingFocus.current = null
+ setRovingId(pendingId)
+ buttonRefs.current.get(pendingId)?.focus()
+ }
+ }, [rovingId, selectableRows])
+
+ useEffect(() => {
+ if (review.selection && shouldFocusEditor.current) {
+ shouldFocusEditor.current = false
+ editorRef.current?.focus()
+ }
+ }, [review.selection])
+
+ const focusButton = (id: string | null) => {
+ if (id) buttonRefs.current.get(id)?.focus()
+ }
+
+ const selectSingleRow = (row: WorkspaceDiffRow, resetStatus: ReviewStatus = null, focusEditor = false) => {
+ const selection = getCompatibleDiffRange(rows, row.id, row.id)
+ if (!selection) return
+ shouldFocusEditor.current = focusEditor
+ setReview((current) => ({
+ ...current,
+ anchorId: row.id,
+ focusId: row.id,
+ selection,
+ }))
+ setStatus(resetStatus)
+ }
+
+ const extendSelection = (row: WorkspaceDiffRow, focusEditor = false) => {
+ if (!review.anchorId) {
+ selectSingleRow(row, null, focusEditor)
+ return
+ }
+ const selection = getCompatibleDiffRange(rows, review.anchorId, row.id)
+ if (!selection) {
+ selectSingleRow(row, 'selectionReset', focusEditor)
+ return
+ }
+ shouldFocusEditor.current = focusEditor
+ setReview((current) => ({ ...current, focusId: row.id, selection }))
+ setStatus(null)
+ }
+
+ const activateRow = (row: WorkspaceDiffRow, extend: boolean, focusEditor: boolean) => {
+ setRovingId(row.id)
+ if (extend) extendSelection(row, focusEditor)
+ else selectSingleRow(row, null, focusEditor)
+ }
+
+ const handleRowClick = (event: MouseEvent, row: WorkspaceDiffRow) => {
+ activateRow(row, event.shiftKey, true)
+ }
+
+ const moveRovingFocus = (row: WorkspaceDiffRow, direction: -1 | 1, extend: boolean) => {
+ const currentIndex = selectableRows.findIndex((candidate) => candidate.id === row.id)
+ const anchorRow = review.anchorId
+ ? selectableRows.find((candidate) => candidate.id === review.anchorId) ?? row
+ : row
+ let target = selectableRows[currentIndex + direction]
+ if (extend) {
+ let targetIndex = currentIndex + direction
+ while (target && (target.side !== anchorRow.side || target.hunkId !== anchorRow.hunkId)) {
+ targetIndex += direction
+ target = selectableRows[targetIndex]
+ }
+ }
+ if (!target) return
+ setRovingId(target.id)
+ focusButton(target.id)
+ if (extend && !review.anchorId) {
+ const selection = getCompatibleDiffRange(rows, row.id, target.id)
+ if (selection) {
+ setReview((current) => ({
+ ...current,
+ anchorId: row.id,
+ focusId: target.id,
+ selection,
+ }))
+ setStatus(null)
+ } else {
+ selectSingleRow(target, 'selectionReset')
+ }
+ } else if (extend) {
+ extendSelection(target)
+ }
+ }
+
+ const handleRowKeyDown = (event: KeyboardEvent, row: WorkspaceDiffRow) => {
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ moveRovingFocus(row, event.key === 'ArrowDown' ? 1 : -1, event.shiftKey)
+ return
+ }
+ if (event.key === 'Home' || event.key === 'End') {
+ event.preventDefault()
+ const navigationRows = event.shiftKey
+ ? selectableRows.filter((candidate) => (
+ candidate.side === row.side && candidate.hunkId === row.hunkId
+ ))
+ : selectableRows
+ const target = event.key === 'Home' ? navigationRows[0] : navigationRows.at(-1)
+ if (target) {
+ setRovingId(target.id)
+ focusButton(target.id)
+ if (event.shiftKey && !review.anchorId) {
+ const selection = getCompatibleDiffRange(rows, row.id, target.id)
+ if (selection) {
+ setReview((current) => ({
+ ...current,
+ anchorId: row.id,
+ focusId: target.id,
+ selection,
+ }))
+ setStatus(null)
+ }
+ } else if (event.shiftKey) {
+ extendSelection(target)
+ }
+ }
+ return
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ activateRow(row, event.shiftKey, true)
+ }
+ }
+
+ const closeEditor = () => {
+ const restoreId = review.anchorId
+ setReview((current) => ({ ...current, anchorId: null, focusId: null, selection: null }))
+ setStatus(null)
+ focusButton(restoreId)
+ }
+
+ const submitComment = () => {
+ const note = review.draft.trim()
+ if (!note || !review.selection) return
+ const { side, lineStart, lineEnd, quote, hunkId } = review.selection
+ onAddComment?.({ side, lineStart, lineEnd, quote, hunkId }, note)
+ const restoreId = review.anchorId
+ setReview(emptyReviewState)
+ setStatus(null)
+ focusButton(restoreId)
+ }
+
+ const handleEditorKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ closeEditor()
+ return
+ }
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault()
+ submitComment()
+ }
+ }
+
+ const toggleRows = () => {
+ if (!showAllRows) {
+ setShowAllRows(true)
+ return
+ }
+
+ const collapsedItemIds = new Set(displayItemIds.slice(0, lineLimit))
+ const collapsedSelectableRows = rows.filter((row) => row.selectable && collapsedItemIds.has(row.id))
+ const nextRovingId = collapsedSelectableRows[0]?.id ?? null
+ const selectionWillBeHidden = review.selection?.rowIds.some((id) => !collapsedItemIds.has(id)) ?? false
+
+ if (selectionWillBeHidden) {
+ setReview((current) => ({
+ ...current,
+ anchorId: null,
+ focusId: null,
+ selection: null,
+ }))
+ setStatus('collapsedSelection')
+ }
+ setRovingId(nextRovingId)
+ pendingRovingFocus.current = nextRovingId
+ setShowAllRows(false)
+ }
+
+ const renderEditor = () => review.selection && (
+
+ {status && (
+
+ {t(`workspace.diffReview.${status}`)}
+
+ )}
+
+
+
+
+ {sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
+
+
+
+
+
+ )
+
+ return (
+
+
+
+ {files.map((file) => {
+ const headerVisible = visibleItemIds.has(`${file.id}-header`)
+ const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id))
+ 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)
+ return (
+
+ {headerVisible && (
+
+ diff --git {oldPath} {newPath}
+
+ )}
+ {fileRows.map((row) => {
+ const line = row.side === 'old' ? row.oldLine : row.newLine
+ const selected = selectedIds.has(row.id)
+ return (
+
+
+
+ {row.oldLine ?? ''}
+
+
+ {row.newLine ?? ''}
+
+
+ {row.selectable && row.side && line !== null && (
+
+ )}
+
+
{row.prefix || ' '}
+
+ {row.selectable && row.text && !usePlainLargePreview
+ ?
+ : row.text || ' '}
+
+
+ {review.selection?.endId === row.id ? renderEditor() : null}
+
+ )
+ })}
+
+ )
+ })}
+
+
+ {status && !review.selection && (
+
+
+ {t(`workspace.diffReview.${status}`)}
+
+
+ )}
+
+ {displayItemIds.length > lineLimit && (
+
+
+ {showAllRows
+ ? t('workspace.previewAllLines', { total: displayItemIds.length })
+ : t('workspace.previewLineLimit', { count: visibleItemIds.size, total: displayItemIds.length })}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx
index 10389ec3..7b0eaf39 100644
--- a/desktop/src/components/workspace/WorkspacePanel.test.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx
@@ -417,6 +417,57 @@ describe('WorkspacePanel', () => {
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})
+ it.each([
+ ['diff:src/app.ts', 'modified', 'Modified'],
+ ['file:src/app.ts', 'added', 'Added'],
+ ] as const)('marks the changed row active for %s and localizes its %s status', async (activeTabId, status, statusLabel) => {
+ const sessionId = `session-active-${status}`
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ [sessionId]: { isOpen: true, activeView: 'changed', hasUserSelectedView: true },
+ },
+ statusBySession: {
+ ...state.statusBySession,
+ [sessionId]: {
+ state: 'ok',
+ workDir: '/repo',
+ repoName: 'repo',
+ branch: 'main',
+ isGitRepo: true,
+ changedFiles: [{ path: 'src/app.ts', status, additions: 2, deletions: 1 }],
+ },
+ },
+ previewTabsBySession: {
+ ...state.previewTabsBySession,
+ [sessionId]: [{
+ id: activeTabId,
+ path: 'src/app.ts',
+ kind: activeTabId.startsWith('diff:') ? 'diff' : 'file',
+ title: 'app.ts',
+ state: 'ok',
+ ...(activeTabId.startsWith('diff:')
+ ? { diff: '@@ -1 +1 @@\n-old\n+new' }
+ : { content: 'const app = true', language: 'typescript', size: 16 }),
+ }],
+ },
+ activePreviewTabIdBySession: {
+ ...state.activePreviewTabIdBySession,
+ [sessionId]: activeTabId,
+ },
+ }))
+
+ const view = await renderPanel(sessionId)
+ await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
+
+ const row = view.getByText('src/app.ts').closest('button')
+ if (!row) throw new Error('Changed file row was not rendered')
+ expect(row.getAttribute('aria-current')).toBe('true')
+ expect(row.className).toContain('bg-[var(--color-surface-selected)]')
+ expect(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
+ })
+
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
@@ -1024,11 +1075,12 @@ describe('WorkspacePanel', () => {
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('+line 1')
- expect(highlightedCode).toContain('+line 2000')
- expect(highlightedCode).not.toContain('+line 2001')
+ expect(highlightedCode).toContain('+line 1999')
+ expect(highlightedCode).not.toContain('+line 2000')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
+ expect(view.getByTestId('workspace-code').textContent).toContain('+line 2001')
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
@@ -1077,11 +1129,11 @@ describe('WorkspacePanel', () => {
const view = await renderPanel('session-wide-diff')
const diffSurface = view.getByTestId('workspace-code')
- const firstRow = diffSurface.querySelector('div')
+ const firstRow = diffSurface.querySelector('[data-diff-row-id]')
expect(firstRow?.className).toContain('w-max')
expect(firstRow?.className).toContain('min-w-full')
- expect(firstRow?.className).toContain('grid-cols-[48px_18px_max-content]')
+ expect(firstRow?.className).toContain('grid-cols-[42px_42px_28px_18px_minmax(max-content,1fr)]')
expect(diffSurface.textContent).toContain(longDiffLine)
})
@@ -1696,6 +1748,87 @@ describe('WorkspacePanel', () => {
])
})
+ it('adds a side-aware diff comment, keeps the diff open, and focuses the composer', async () => {
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-diff-comment': {
+ isOpen: true,
+ activeView: 'changed',
+ },
+ },
+ statusBySession: {
+ ...state.statusBySession,
+ 'session-diff-comment': {
+ state: 'ok',
+ workDir: '/repo',
+ repoName: 'repo',
+ branch: 'main',
+ isGitRepo: true,
+ changedFiles: [],
+ },
+ },
+ previewTabsBySession: {
+ ...state.previewTabsBySession,
+ 'session-diff-comment': [{
+ id: 'diff:src/a.ts',
+ path: 'src/a.ts',
+ kind: 'diff',
+ title: 'a.ts',
+ diff: '@@ -10,2 +11,2 @@\n-const result = makeResult()\n-return result\n+const result = buildResult()\n+return result',
+ state: 'ok',
+ }],
+ },
+ activePreviewTabIdBySession: {
+ ...state.activePreviewTabIdBySession,
+ 'session-diff-comment': 'diff:src/a.ts',
+ },
+ }))
+
+ const otherComposerShell = document.createElement('div')
+ otherComposerShell.dataset.testid = 'chat-input-shell'
+ otherComposerShell.dataset.sessionId = 'another-session'
+ otherComposerShell.append(document.createElement('textarea'))
+ document.body.append(otherComposerShell)
+
+ const composerShell = document.createElement('div')
+ composerShell.dataset.testid = 'chat-input-shell'
+ composerShell.dataset.sessionId = 'session-diff-comment'
+ const composer = document.createElement('textarea')
+ composerShell.append(composer)
+ document.body.append(composerShell)
+
+ const view = await renderPanel('session-diff-comment')
+
+ await clickElement(view.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
+ const editor = view.getByRole('textbox', { name: 'Review comment' })
+ await act(() => {
+ fireEvent.change(editor, { target: { value: 'Use a shared helper' } })
+ })
+ await clickElement(view.getByRole('button', { name: 'Submit review comment' }))
+
+ await waitFor(() => expect(document.activeElement).toBe(composer))
+ expect(useWorkspaceChatContextStore.getState().referencesBySession['session-diff-comment']).toMatchObject([
+ {
+ kind: 'code-comment',
+ path: 'src/a.ts',
+ absolutePath: '/repo/src/a.ts',
+ name: 'a.ts',
+ lineStart: 11,
+ lineEnd: 11,
+ diffSide: 'new',
+ hunkId: expect.any(String),
+ note: 'Use a shared helper',
+ quote: 'const result = buildResult()',
+ },
+ ])
+ expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-diff-comment']).toBe('diff:src/a.ts')
+ expect(view.getByTestId('workspace-code')).toBeTruthy()
+ composerShell.remove()
+ otherComposerShell.remove()
+ })
+
it('adds selected code from a preview to the chat context without requiring a note', async () => {
await setWorkspaceState((state) => ({
...state,
@@ -2068,4 +2201,137 @@ describe('WorkspacePanel', () => {
expect(view.getByText('status failed')).toBeTruthy()
})
})
+
+ it('keeps a loaded diff visible and marks the preview busy while it refreshes', async () => {
+ const refresh = deferred<{ state: 'ok'; path: string; diff: string }>()
+ getMocks().getWorkspaceDiffMock.mockReturnValue(refresh.promise)
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-preview-refresh': { isOpen: true, activeView: 'changed' },
+ },
+ previewTabsBySession: {
+ ...state.previewTabsBySession,
+ 'session-preview-refresh': [{
+ id: 'diff:src/a.ts',
+ path: 'src/a.ts',
+ kind: 'diff',
+ title: 'a.ts',
+ state: 'ok',
+ diff: '@@ -1 +1 @@\n-old\n+cached',
+ }],
+ },
+ activePreviewTabIdBySession: {
+ ...state.activePreviewTabIdBySession,
+ 'session-preview-refresh': 'diff:src/a.ts',
+ },
+ }))
+
+ const view = await renderPanel('session-preview-refresh')
+ expect(view.getByText('cached')).toBeTruthy()
+
+ await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
+
+ expect(view.getByText('cached')).toBeTruthy()
+ expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
+
+ refresh.resolve({
+ state: 'ok',
+ path: 'src/a.ts',
+ diff: '@@ -1 +1 @@\n-old\n+latest',
+ })
+ await flushReactWork()
+
+ expect(view.getByText('latest')).toBeTruthy()
+ expect(view.queryByText('cached')).toBeNull()
+ expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('false')
+ })
+
+ it('localizes an initial missing preview without describing it as a refresh failure', async () => {
+ await setSettingsState({ ...settingsInitialState, locale: 'zh' })
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-initial-missing-zh': { isOpen: true, activeView: 'changed' },
+ },
+ previewTabsBySession: {
+ ...state.previewTabsBySession,
+ 'session-initial-missing-zh': [{
+ id: 'diff:src/missing.ts',
+ path: 'src/missing.ts',
+ kind: 'diff',
+ title: 'missing.ts',
+ state: 'missing',
+ }],
+ },
+ activePreviewTabIdBySession: {
+ ...state.activePreviewTabIdBySession,
+ 'session-initial-missing-zh': 'diff:src/missing.ts',
+ },
+ }))
+
+ const view = await renderPanel('session-initial-missing-zh')
+
+ expect(view.getByText('文件不存在。')).toBeTruthy()
+ expect(view.queryByText(/refresh/i)).toBeNull()
+ expect(view.queryByRole('button', { name: '重试' })).toBeNull()
+ })
+
+ it('localizes stale diff state and completes retry after a non-ok refresh omits an error', async () => {
+ const refresh = deferred<{ state: 'missing'; path: string }>()
+ const retry = deferred<{ state: 'ok'; path: string; diff: string }>()
+ getMocks().getWorkspaceDiffMock
+ .mockReturnValueOnce(refresh.promise)
+ .mockReturnValueOnce(retry.promise)
+ await setSettingsState({ ...settingsInitialState, locale: 'zh' })
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-preview-refresh-error': { isOpen: true, activeView: 'changed' },
+ },
+ previewTabsBySession: {
+ ...state.previewTabsBySession,
+ 'session-preview-refresh-error': [{
+ id: 'diff:src/a.ts',
+ path: 'src/a.ts',
+ kind: 'diff',
+ title: 'a.ts',
+ state: 'ok',
+ diff: '@@ -1 +1 @@\n-old\n+cached',
+ }],
+ },
+ activePreviewTabIdBySession: {
+ ...state.activePreviewTabIdBySession,
+ 'session-preview-refresh-error': 'diff:src/a.ts',
+ },
+ }))
+
+ const view = await renderPanel('session-preview-refresh-error')
+ await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
+ refresh.resolve({ state: 'missing', path: 'src/a.ts' })
+ await flushReactWork()
+
+ expect(view.getByText('cached')).toBeTruthy()
+ expect(view.getByRole('alert').textContent).toContain('文件不存在。')
+ expect(view.getByRole('alert').textContent).not.toMatch(/refresh/i)
+ await clickElement(view.getByRole('button', { name: '重试' }))
+
+ expect(view.queryByRole('alert')).toBeNull()
+ expect(view.getByText('cached')).toBeTruthy()
+ expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
+
+ retry.resolve({
+ state: 'ok',
+ path: 'src/a.ts',
+ diff: '@@ -1 +1 @@\n-old\n+recovered',
+ })
+ await flushReactWork()
+
+ expect(view.getByText('recovered')).toBeTruthy()
+ expect(view.queryByText('cached')).toBeNull()
+ expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('false')
+ })
})
diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx
index 454d7423..60c4f3dc 100644
--- a/desktop/src/components/workspace/WorkspacePanel.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
-import { MessageCircle } from 'lucide-react'
+import { CircleAlert, Code2, File as FileIcon, FileText, Image as ImageIcon, MessageCircle, RefreshCw, Settings2, type LucideIcon } from 'lucide-react'
import { Highlight } from 'prism-react-renderer'
import type {
WorkspaceChangedFile,
@@ -27,8 +27,10 @@ import {
WORKSPACE_PREVIEW_LINE_LIMIT,
WorkspaceDiffSurface,
workspacePrismTheme,
+ type WorkspaceDiffCommentSelection,
} from './WorkspaceCodeSurface'
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
+import { getFileIdentity, getWorkspaceStatusLabel, type WorkspaceFileIdentity } from './fileIdentity'
type WorkspacePanelProps = {
sessionId: string
@@ -102,6 +104,14 @@ const FILE_STATUS_META: Record = {
+ code: Code2,
+ config: Settings2,
+ document: FileText,
+ image: ImageIcon,
+ file: FileIcon,
+}
+
const EMPTY_TREE_BY_PATH: Record = {}
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
const EMPTY_EXPANDED_PATHS: string[] = []
@@ -435,11 +445,12 @@ function WorkspaceFilterInput({
}
function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
+ const t = useTranslation()
const meta = FILE_STATUS_META[status]
return (
{meta.label}
@@ -765,23 +776,43 @@ function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
function ChangedFileRow({
file,
+ active,
onClick,
onContextMenu,
}: {
file: WorkspaceChangedFile
+ active: boolean
onClick: () => void
onContextMenu: (event: MouseEvent, path: string) => void
}) {
+ const identity = getFileIdentity(file.path)
+ const IdentityIcon = FILE_IDENTITY_ICONS[identity.icon]
+
return (