mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
fix(desktop): refine diff review workbench #1004
This commit is contained in:
parent
f44442033e
commit
cc1eb4ee3a
@ -49,6 +49,52 @@ describe('WorkspaceDiffSurface', () => {
|
|||||||
highlightRenderSpy.mockClear()
|
highlightRenderSpy.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps one scroll surface while hiding redundant single-file patch chrome', () => {
|
||||||
|
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" hideSingleFileHeader />)
|
||||||
|
|
||||||
|
const scrollSurface = screen.getByTestId('workspace-diff-scroll')
|
||||||
|
expect(scrollSurface.className).toContain('min-h-0')
|
||||||
|
expect(scrollSurface.className).toContain('overflow-auto')
|
||||||
|
expect(scrollSurface).toHaveStyle({ containerType: 'inline-size' })
|
||||||
|
expect(screen.getByTestId('workspace-diff-content').className).toContain('w-max')
|
||||||
|
expect(screen.queryByTestId('workspace-diff-file-header')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('--- a/src/a.ts')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('+++ b/src/a.ts')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('@@ -10,2 +10,3 @@')).toBeInTheDocument()
|
||||||
|
expect(getCodeRow('const a = 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not spend the visible line limit on hidden single-file patch metadata', () => {
|
||||||
|
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" hideSingleFileHeader lineLimit={2} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('@@ -10,2 +10,3 @@')).toBeInTheDocument()
|
||||||
|
expect(getCodeRow('const a = 1')).toBeInTheDocument()
|
||||||
|
expect(document.querySelector('[data-row-text="const b = 2"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the Codex-style compact number gutter without a dedicated comment column', () => {
|
||||||
|
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||||
|
|
||||||
|
const code = screen.getByTestId('workspace-code')
|
||||||
|
const row = getCodeRow('const b = 3').closest<HTMLElement>('[data-diff-row-id]')
|
||||||
|
const gutter = row?.querySelector<HTMLElement>('[data-diff-number-gutter]')
|
||||||
|
const commentButton = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
|
||||||
|
|
||||||
|
expect(code.style.getPropertyValue('--workspace-diff-gutter-width')).toBe('6ch')
|
||||||
|
expect(row).toHaveStyle({
|
||||||
|
gridTemplateColumns: 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)',
|
||||||
|
})
|
||||||
|
expect(gutter).toHaveTextContent('11')
|
||||||
|
expect(gutter).toContainElement(commentButton)
|
||||||
|
expect(gutter?.querySelector('[data-diff-gutter-utility-slot]')).toContainElement(commentButton)
|
||||||
|
expect(gutter?.className).toContain('bg-[var(--color-diff-added-bg)]')
|
||||||
|
expect(getCodeRow('const a = 1').closest('[data-diff-row-id]')?.querySelector('[data-diff-number-gutter]')?.className).toContain('bg-[var(--color-code-bg)]')
|
||||||
|
expect(commentButton.className).toContain('h-5')
|
||||||
|
expect(commentButton.className).toContain('w-5')
|
||||||
|
expect(row?.querySelectorAll('[data-diff-line-number]')).toHaveLength(1)
|
||||||
|
expect(row?.className).toContain('min-h-5')
|
||||||
|
})
|
||||||
|
|
||||||
it('submits a forward range with its source coordinates and quote', () => {
|
it('submits a forward range with its source coordinates and quote', () => {
|
||||||
const onAddComment = vi.fn()
|
const onAddComment = vi.fn()
|
||||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
|
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
|
||||||
@ -104,12 +150,23 @@ describe('WorkspaceDiffSurface', () => {
|
|||||||
expect(lastRow).toHaveAttribute('aria-selected', 'true')
|
expect(lastRow).toHaveAttribute('aria-selected', 'true')
|
||||||
expect(lastRow).toHaveAttribute('data-range-edge', 'end')
|
expect(lastRow).toHaveAttribute('data-range-edge', 'end')
|
||||||
expect(document.querySelectorAll('[data-diff-selection-rail]')).toHaveLength(3)
|
expect(document.querySelectorAll('[data-diff-selection-rail]')).toHaveLength(3)
|
||||||
|
document.querySelectorAll('[data-diff-selection-rail]').forEach((rail) => {
|
||||||
|
expect(rail.closest('[data-diff-number-gutter]')).not.toBeNull()
|
||||||
|
})
|
||||||
expect(firstRow?.className).toContain('bg-[var(--color-info-container)]')
|
expect(firstRow?.className).toContain('bg-[var(--color-info-container)]')
|
||||||
expect(firstRow?.className).not.toContain('bg-[var(--color-diff-added-bg)]')
|
expect(firstRow?.className).not.toContain('bg-[var(--color-diff-added-bg)]')
|
||||||
expect(screen.getByTestId('workspace-code').className).toContain('text-[13px]')
|
expect(screen.getByTestId('workspace-code').className).toContain('text-[13px]')
|
||||||
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
||||||
expect(editor.closest('[data-diff-editor]')?.className).toContain('rounded-[10px]')
|
const editorContainer = editor.closest('[data-diff-editor]')
|
||||||
expect(editor.className).toContain('var(--color-info)')
|
expect(editorContainer?.className).toContain('max-w-3xl')
|
||||||
|
expect(editorContainer?.className).toContain('sticky')
|
||||||
|
expect(editorContainer).toHaveStyle({ left: 'var(--workspace-diff-gutter-width)' })
|
||||||
|
expect(editorContainer).toHaveStyle({ width: 'min(48rem, calc(100cqi - var(--workspace-diff-gutter-width) - 0.75rem))' })
|
||||||
|
expect(editorContainer?.className).not.toContain('ml-[116px]')
|
||||||
|
expect(editorContainer?.className).not.toContain('min-w-[420px]')
|
||||||
|
expect(editorContainer).toHaveTextContent('Local comment')
|
||||||
|
expect(editor.className).toContain('min-h-0')
|
||||||
|
expect(editor.className).not.toContain('shadow-[inset_0_0_0_1px')
|
||||||
expect(screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })).not.toHaveAttribute('data-selection-focus')
|
expect(screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })).not.toHaveAttribute('data-selection-focus')
|
||||||
const focusedGutter = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
|
const focusedGutter = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
|
||||||
expect(focusedGutter).toHaveAttribute('data-selection-focus', 'true')
|
expect(focusedGutter).toHaveAttribute('data-selection-focus', 'true')
|
||||||
@ -257,14 +314,14 @@ describe('WorkspaceDiffSurface', () => {
|
|||||||
|
|
||||||
expect(lastVisibleButton).toHaveFocus()
|
expect(lastVisibleButton).toHaveFocus()
|
||||||
expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
|
expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
|
||||||
expect(screen.getByText('Showing first 5 of 11 loaded lines.')).toBeInTheDocument()
|
expect(screen.getByText('Showing first 5 of 9 loaded lines.')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Show all 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', () => {
|
it('invalidates a hidden selection on collapse while preserving its draft and visible roving target', () => {
|
||||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" lineLimit={5} />)
|
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" lineLimit={5} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
|
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.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }))
|
||||||
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
|
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
|
||||||
target: { value: 'Keep this collapsed draft' },
|
target: { value: 'Keep this collapsed draft' },
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
@ -110,6 +111,7 @@ export interface WorkspaceDiffSurfaceProps {
|
|||||||
path: string
|
path: string
|
||||||
className?: string
|
className?: string
|
||||||
lineLimit?: number
|
lineLimit?: number
|
||||||
|
hideSingleFileHeader?: boolean
|
||||||
onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
|
onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,6 +138,14 @@ function rowTone(row: WorkspaceDiffRow) {
|
|||||||
return 'hover:bg-[var(--color-surface-hover)]'
|
return 'hover:bg-[var(--color-surface-hover)]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gutterTone(row: WorkspaceDiffRow, selected: boolean) {
|
||||||
|
if (selected) return 'bg-[var(--color-info-container)]'
|
||||||
|
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 'bg-[var(--color-code-bg)] group-hover:bg-[var(--color-surface-hover)]'
|
||||||
|
}
|
||||||
|
|
||||||
function prefixTone(row: WorkspaceDiffRow) {
|
function prefixTone(row: WorkspaceDiffRow) {
|
||||||
if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]'
|
if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]'
|
||||||
if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]'
|
if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]'
|
||||||
@ -148,19 +158,40 @@ function codeTone(row: WorkspaceDiffRow) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStructuralMetadata(row: WorkspaceDiffRow) {
|
||||||
|
if (row.kind !== 'metadata') return false
|
||||||
|
return row.text.startsWith('diff --') || row.text.startsWith('--- ') || row.text.startsWith('+++ ')
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceDiffSurface({
|
export function WorkspaceDiffSurface({
|
||||||
value,
|
value,
|
||||||
path,
|
path,
|
||||||
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
||||||
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||||
|
hideSingleFileHeader = false,
|
||||||
onAddComment,
|
onAddComment,
|
||||||
}: WorkspaceDiffSurfaceProps) {
|
}: WorkspaceDiffSurfaceProps) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const files = useMemo(() => parseWorkspaceDiff(value), [value])
|
const files = useMemo(() => parseWorkspaceDiff(value), [value])
|
||||||
const rows = useMemo(() => files.flatMap((file) => file.rows), [files])
|
const rows = useMemo(() => files.flatMap((file) => file.rows), [files])
|
||||||
|
const lineNumberCharacters = useMemo(
|
||||||
|
() => rows.reduce((maximum, row) => Math.max(
|
||||||
|
maximum,
|
||||||
|
row.oldLine === null ? 0 : String(row.oldLine).length,
|
||||||
|
row.newLine === null ? 0 : String(row.newLine).length,
|
||||||
|
), 3),
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const codeStyle = {
|
||||||
|
'--workspace-diff-gutter-width': `${lineNumberCharacters + 3}ch`,
|
||||||
|
} as CSSProperties
|
||||||
|
const showFileHeaders = !hideSingleFileHeader || files.length > 1
|
||||||
const displayItemIds = useMemo(
|
const displayItemIds = useMemo(
|
||||||
() => files.flatMap((file) => [`${file.id}-header`, ...file.rows.map((row) => row.id)]),
|
() => files.flatMap((file) => [
|
||||||
[files],
|
...(showFileHeaders ? [`${file.id}-header`] : []),
|
||||||
|
...file.rows.filter((row) => !isStructuralMetadata(row)).map((row) => row.id),
|
||||||
|
]),
|
||||||
|
[files, showFileHeaders],
|
||||||
)
|
)
|
||||||
const [review, setReview] = useState<ReviewState>(emptyReviewState)
|
const [review, setReview] = useState<ReviewState>(emptyReviewState)
|
||||||
const [status, setStatus] = useState<ReviewStatus>(null)
|
const [status, setStatus] = useState<ReviewStatus>(null)
|
||||||
@ -402,21 +433,26 @@ export function WorkspaceDiffSurface({
|
|||||||
const renderEditor = () => review.selection && (
|
const renderEditor = () => review.selection && (
|
||||||
<div
|
<div
|
||||||
data-diff-editor=""
|
data-diff-editor=""
|
||||||
className="my-3 ml-[116px] mr-4 min-w-[420px] rounded-[10px] bg-[var(--color-surface-container-low)] p-3 shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_9%,transparent)]"
|
className="sticky z-[2] my-1.5 min-w-[280px] max-w-3xl overflow-hidden rounded-[10px] bg-[var(--color-surface-container-low)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_9%,transparent)]"
|
||||||
|
style={{
|
||||||
|
left: 'var(--workspace-diff-gutter-width)',
|
||||||
|
width: 'min(48rem, calc(100cqi - var(--workspace-diff-gutter-width) - 0.75rem))',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{status && (
|
<div className="flex min-h-10 items-center gap-2 px-3 pt-2.5">
|
||||||
<div role="status" aria-live="polite" className="mb-1.5 text-[11px] text-[var(--color-warning)]">
|
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
|
||||||
{t(`workspace.diffReview.${status}`)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] bg-[var(--color-info-container)] text-[var(--color-info)]">
|
|
||||||
<MessageSquare aria-hidden="true" size={14} />
|
<MessageSquare aria-hidden="true" size={14} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">
|
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</div>
|
||||||
|
<div className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
|
||||||
{sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
|
{sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{status && (
|
||||||
|
<div role="status" aria-live="polite" className="px-3 pt-1.5 text-[11px] text-[var(--color-warning)]">
|
||||||
|
{t(`workspace.diffReview.${status}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
aria-label={t('workspace.diffReview.editorLabel')}
|
aria-label={t('workspace.diffReview.editorLabel')}
|
||||||
@ -424,10 +460,10 @@ export function WorkspaceDiffSurface({
|
|||||||
placeholder={t('workspace.commentPlaceholder')}
|
placeholder={t('workspace.commentPlaceholder')}
|
||||||
onChange={(event) => setReview((current) => ({ ...current, draft: event.target.value }))}
|
onChange={(event) => setReview((current) => ({ ...current, draft: event.target.value }))}
|
||||||
onKeyDown={handleEditorKeyDown}
|
onKeyDown={handleEditorKeyDown}
|
||||||
rows={3}
|
rows={2}
|
||||||
className="block min-h-[88px] w-full resize-y rounded-[8px] bg-[var(--color-surface)] px-3 py-2.5 font-[var(--font-body)] text-[13px] leading-5 text-[var(--color-text-primary)] outline-none shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_10%,transparent)] transition-[box-shadow] duration-200 ease-out placeholder:text-[var(--color-text-tertiary)] focus:shadow-[inset_0_0_0_1px_var(--color-info),0_0_0_3px_color-mix(in_srgb,var(--color-info)_12%,transparent)]"
|
className="block min-h-0 w-full resize-y bg-transparent px-3 py-2 font-[var(--font-body)] text-[13px] leading-5 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2.5 flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2 px-2 pb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeEditor}
|
onClick={closeEditor}
|
||||||
@ -450,18 +486,19 @@ export function WorkspaceDiffSurface({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div data-testid="workspace-diff-scroll" className={className} style={{ containerType: 'inline-size' }}>
|
||||||
<div className="relative min-w-max pb-3">
|
<div data-testid="workspace-diff-content" className="relative min-w-full w-max pb-3">
|
||||||
<div
|
<div
|
||||||
data-workspace-code=""
|
data-workspace-code=""
|
||||||
data-testid="workspace-code"
|
data-testid="workspace-code"
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label={`${path} diff`}
|
aria-label={`${path} diff`}
|
||||||
className="m-0 font-[var(--font-mono)] text-[13px] leading-[1.65] text-[var(--color-code-fg)]"
|
className="m-0 min-w-full font-[var(--font-mono)] text-[13px] leading-5 text-[var(--color-code-fg)]"
|
||||||
|
style={codeStyle}
|
||||||
>
|
>
|
||||||
{files.map((file) => {
|
{files.map((file) => {
|
||||||
const headerVisible = visibleItemIds.has(`${file.id}-header`)
|
const headerVisible = showFileHeaders && visibleItemIds.has(`${file.id}-header`)
|
||||||
const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id))
|
const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id) && !isStructuralMetadata(row))
|
||||||
if (!headerVisible && fileRows.length === 0) return null
|
if (!headerVisible && fileRows.length === 0) return null
|
||||||
const oldPath = file.oldPath ? `a/${file.oldPath}` : '/dev/null'
|
const oldPath = file.oldPath ? `a/${file.oldPath}` : '/dev/null'
|
||||||
const newPath = file.newPath ? `b/${file.newPath}` : '/dev/null'
|
const newPath = file.newPath ? `b/${file.newPath}` : '/dev/null'
|
||||||
@ -512,9 +549,14 @@ export function WorkspaceDiffSurface({
|
|||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
data-diff-row-id={row.id}
|
data-diff-row-id={row.id}
|
||||||
data-range-edge={rangeEdge}
|
data-range-edge={rangeEdge}
|
||||||
className={`group relative grid min-h-[30px] min-w-full w-max grid-cols-[46px_46px_30px_20px_minmax(max-content,1fr)] items-stretch px-3 ${
|
className={`group relative grid min-w-full w-max items-stretch ${row.kind === 'hunk' ? 'min-h-8' : 'min-h-5'} ${
|
||||||
selected ? 'bg-[var(--color-info-container)]' : rowTone(row)
|
selected ? 'bg-[var(--color-info-container)]' : rowTone(row)
|
||||||
}`}
|
}`}
|
||||||
|
style={{ gridTemplateColumns: 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-diff-number-gutter=""
|
||||||
|
className={`sticky left-0 z-[1] flex min-h-full select-none items-center justify-end pl-[2ch] pr-[1ch] text-right text-[11px] text-[var(--color-text-tertiary)] ${gutterTone(row, selected)}`}
|
||||||
>
|
>
|
||||||
{selected && (
|
{selected && (
|
||||||
<span
|
<span
|
||||||
@ -525,14 +567,17 @@ export function WorkspaceDiffSurface({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="select-none self-center text-right text-[11px] text-[var(--color-text-tertiary)]">
|
<span
|
||||||
{row.oldLine ?? ''}
|
data-diff-line-number=""
|
||||||
|
className={`transition-opacity duration-100 group-hover:opacity-0 group-focus-within:opacity-0 ${selectionFocus ? 'opacity-0' : ''}`}
|
||||||
|
>
|
||||||
|
{line ?? ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="select-none self-center text-right text-[11px] text-[var(--color-text-tertiary)]">
|
|
||||||
{row.newLine ?? ''}
|
|
||||||
</span>
|
|
||||||
<span className="flex h-[30px] w-[30px] items-center justify-center">
|
|
||||||
{row.selectable && row.side && line !== null && (
|
{row.selectable && row.side && line !== null && (
|
||||||
|
<span
|
||||||
|
data-diff-gutter-utility-slot=""
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center justify-end"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
if (element) buttonRefs.current.set(row.id, element)
|
if (element) buttonRefs.current.set(row.id, element)
|
||||||
@ -550,24 +595,27 @@ export function WorkspaceDiffSurface({
|
|||||||
onClick={(event) => handleRowClick(event, row)}
|
onClick={(event) => handleRowClick(event, row)}
|
||||||
onFocus={() => setRovingId(row.id)}
|
onFocus={() => setRovingId(row.id)}
|
||||||
onKeyDown={(event) => handleRowKeyDown(event, row)}
|
onKeyDown={(event) => handleRowKeyDown(event, row)}
|
||||||
className={`inline-flex h-7 w-7 items-center justify-center rounded-[6px] transition-[color,background-color,opacity,transform] duration-200 ease-out hover:bg-[var(--color-info)] hover:text-[var(--color-surface)] active:scale-[0.96] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--color-info)] ${
|
className={`relative right-[calc(1ch-1.25rem)] inline-flex h-5 w-5 items-center justify-center rounded-[4px] transition-[color,background-color,opacity,transform] duration-100 ease-out before:absolute before:-inset-1 hover:bg-[var(--color-info)] hover:text-[var(--color-surface)] active:scale-[0.96] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-1px] focus-visible:outline-[var(--color-info)] ${
|
||||||
selectionFocus ? 'bg-[var(--color-info)] text-[var(--color-surface)] opacity-100' : 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 focus:opacity-100'
|
selectionFocus ? 'bg-[var(--color-info)] text-[var(--color-surface)] opacity-100' : 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selected ? <MessageSquare aria-hidden="true" size={14} /> : <Plus aria-hidden="true" size={14} />}
|
{selected ? <MessageSquare aria-hidden="true" size={12} /> : <Plus aria-hidden="true" size={13} />}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`select-none self-center text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
|
|
||||||
<span
|
<span
|
||||||
data-row-text={row.text}
|
data-row-text={row.text}
|
||||||
data-selected={selected ? 'true' : undefined}
|
data-selected={selected ? 'true' : undefined}
|
||||||
className={`whitespace-pre self-center pr-6 ${codeTone(row)}`}
|
className="whitespace-pre self-center pr-6"
|
||||||
>
|
>
|
||||||
|
<span className={`inline-block w-[2ch] select-none text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
|
||||||
|
<span className={codeTone(row)}>
|
||||||
{row.selectable && row.text && !usePlainLargePreview
|
{row.selectable && row.text && !usePlainLargePreview
|
||||||
? <InlineHighlightedCode value={row.text} language={language} />
|
? <InlineHighlightedCode value={row.text} language={language} />
|
||||||
: row.text || ' '}
|
: row.text || ' '}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{review.selection?.endId === row.id ? renderEditor() : null}
|
{review.selection?.endId === row.id ? renderEditor() : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@ -417,18 +417,27 @@ describe('WorkspacePanel', () => {
|
|||||||
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
|
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
|
||||||
})
|
})
|
||||||
expect(view.queryByRole('tablist', { name: 'Preview tabs' })).toBeNull()
|
expect(view.queryByRole('tablist', { name: 'Preview tabs' })).toBeNull()
|
||||||
expect(view.getByTestId('workspace-preview-header').textContent).toContain('src/app.ts')
|
const previewHeader = view.getByTestId('workspace-preview-header')
|
||||||
|
expect(previewHeader.textContent).toContain('src/app.ts')
|
||||||
|
expect(previewHeader.textContent).toContain('+4')
|
||||||
|
expect(previewHeader.textContent).toContain('-1')
|
||||||
|
expect(view.queryByTestId('workspace-review-toolbar')).toBeNull()
|
||||||
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
|
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
|
||||||
|
expect(view.getByTestId('workspace-review-layout').className).toContain('overflow-hidden')
|
||||||
|
expect(view.getByTestId('workspace-preview-column').className).toContain('min-h-0')
|
||||||
|
expect(view.getByTestId('workspace-preview-column').className).toContain('overflow-hidden')
|
||||||
|
expect(previewHeader.textContent).not.toContain('DIFF')
|
||||||
expect(view.queryByTestId('workspace-file-navigator')).toBeNull()
|
expect(view.queryByTestId('workspace-file-navigator')).toBeNull()
|
||||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||||
expect(view.getByTestId('workspace-file-navigator').className).toContain('absolute')
|
expect(view.getByTestId('workspace-file-navigator').className).toContain('absolute')
|
||||||
|
expect(view.queryByTestId('workspace-file-navigator-header')).toBeNull()
|
||||||
|
expect(view.queryByText('1 file')).toBeNull()
|
||||||
expect(view.getByTestId('workspace-file-navigator').className).toContain('w-[min(280px,100%)]')
|
expect(view.getByTestId('workspace-file-navigator').className).toContain('w-[min(280px,100%)]')
|
||||||
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
|
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
|
||||||
const expandedPanel = view.getByTestId('workspace-panel')
|
const expandedPanel = view.getByTestId('workspace-panel')
|
||||||
expect(expandedPanel.style.width).toBe('860px')
|
expect(expandedPanel.style.width).toBe('860px')
|
||||||
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
|
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
|
||||||
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
|
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
|
||||||
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@ -482,7 +491,7 @@ describe('WorkspacePanel', () => {
|
|||||||
expect(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
|
expect(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('presents one review toolbar and reports filtered changed-file results', async () => {
|
it('keeps navigator controls and filter feedback in one header when no file is open', async () => {
|
||||||
const sessionId = 'session-review-toolbar'
|
const sessionId = 'session-review-toolbar'
|
||||||
await setWorkspaceState((state) => ({
|
await setWorkspaceState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
@ -509,16 +518,16 @@ describe('WorkspacePanel', () => {
|
|||||||
|
|
||||||
const view = await renderPanel(sessionId)
|
const view = await renderPanel(sessionId)
|
||||||
|
|
||||||
const toolbar = view.getByTestId('workspace-review-toolbar')
|
expect(view.queryByTestId('workspace-review-toolbar')).toBeNull()
|
||||||
expect(toolbar.getAttribute('aria-label')).toBe('Review workspace')
|
const navigatorHeader = view.getByTestId('workspace-file-navigator-header')
|
||||||
expect(toolbar.tagName).toBe('HEADER')
|
expect(navigatorHeader.tagName).toBe('HEADER')
|
||||||
expect(toolbar.textContent).toContain('claude-code-haha')
|
expect(navigatorHeader.textContent).toContain('Changed files')
|
||||||
expect(toolbar.textContent).toContain('main')
|
expect(view.getByRole('button', { name: /Refresh/ })).toBeTruthy()
|
||||||
expect(toolbar.textContent).toContain('+15')
|
expect(view.queryByText('claude-code-haha')).toBeNull()
|
||||||
expect(toolbar.textContent).toContain('-3')
|
expect(view.queryByText('main')).toBeNull()
|
||||||
|
|
||||||
const filter = view.getByPlaceholderText('Filter files...')
|
const filter = view.getByPlaceholderText('Filter files...')
|
||||||
expect(view.getByText('3 files')).toBeTruthy()
|
expect(view.queryByText('3 files')).toBeNull()
|
||||||
fireEvent.change(filter, { target: { value: 'theme' } })
|
fireEvent.change(filter, { target: { value: 'theme' } })
|
||||||
|
|
||||||
expect(view.getByText('1 of 3 files')).toBeTruthy()
|
expect(view.getByText('1 of 3 files')).toBeTruthy()
|
||||||
@ -566,6 +575,9 @@ describe('WorkspacePanel', () => {
|
|||||||
const view = await renderPanel(sessionId, { embedded: true, forceVisible: true })
|
const view = await renderPanel(sessionId, { embedded: true, forceVisible: true })
|
||||||
|
|
||||||
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-[minmax(0,1fr)_280px]')
|
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-[minmax(0,1fr)_280px]')
|
||||||
|
expect(view.getByTestId('workspace-review-layout').className).toContain('overflow-hidden')
|
||||||
|
expect(view.getByTestId('workspace-preview-column').className).toContain('min-h-0')
|
||||||
|
expect(view.getByTestId('workspace-preview-column').className).toContain('overflow-hidden')
|
||||||
expect(view.getByTestId('workspace-file-navigator').className).not.toContain('absolute')
|
expect(view.getByTestId('workspace-file-navigator').className).not.toContain('absolute')
|
||||||
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
|
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
@ -644,7 +656,7 @@ describe('WorkspacePanel', () => {
|
|||||||
const oldPath = view.getByText('desktop/src/components/workspace/LegacyWorkspacePanelWithALongName.tsx')
|
const oldPath = view.getByText('desktop/src/components/workspace/LegacyWorkspacePanelWithALongName.tsx')
|
||||||
const renamedRow = oldPath.closest('button')
|
const renamedRow = oldPath.closest('button')
|
||||||
|
|
||||||
expect(renamedRow?.className).toContain('min-h-[52px]')
|
expect(renamedRow?.className).toContain('min-h-11')
|
||||||
expect(renamedRow?.className).not.toContain('h-9')
|
expect(renamedRow?.className).not.toContain('h-9')
|
||||||
expect(view.getByText('next.ts')).toBeTruthy()
|
expect(view.getByText('next.ts')).toBeTruthy()
|
||||||
})
|
})
|
||||||
@ -844,7 +856,7 @@ describe('WorkspacePanel', () => {
|
|||||||
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
|
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
|
||||||
expect(await view.findByText('src')).toBeTruthy()
|
expect(await view.findByText('src')).toBeTruthy()
|
||||||
expect(await view.findByText('README.md')).toBeTruthy()
|
expect(await view.findByText('README.md')).toBeTruthy()
|
||||||
expect(view.getByRole('status').textContent).toBe('2 items')
|
expect(view.queryByRole('status')).toBeNull()
|
||||||
expect(view.queryByText('No changes')).toBeNull()
|
expect(view.queryByText('No changes')).toBeNull()
|
||||||
|
|
||||||
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'readme' } })
|
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'readme' } })
|
||||||
@ -965,7 +977,6 @@ describe('WorkspacePanel', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true')
|
expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true')
|
||||||
})
|
})
|
||||||
expect(view.getAllByText('File').length).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders multiple preview tabs and closes only the exact requested tab', async () => {
|
it('renders multiple preview tabs and closes only the exact requested tab', async () => {
|
||||||
@ -1097,13 +1108,13 @@ describe('WorkspacePanel', () => {
|
|||||||
|
|
||||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||||
|
|
||||||
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
expect(view.queryByRole('button', { name: 'Changed files' })).toBeNull()
|
||||||
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
|
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
|
||||||
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
||||||
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
|
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defers all-files tree loading while the file navigator is hidden behind a preview', async () => {
|
it('keeps a preview navigator scoped to changed files when the previous view was all files', async () => {
|
||||||
getMocks().getWorkspaceTreeMock.mockResolvedValue({
|
getMocks().getWorkspaceTreeMock.mockResolvedValue({
|
||||||
state: 'ok',
|
state: 'ok',
|
||||||
path: '',
|
path: '',
|
||||||
@ -1128,7 +1139,12 @@ describe('WorkspacePanel', () => {
|
|||||||
repoName: 'repo',
|
repoName: 'repo',
|
||||||
branch: 'main',
|
branch: 'main',
|
||||||
isGitRepo: true,
|
isGitRepo: true,
|
||||||
changedFiles: [],
|
changedFiles: [{
|
||||||
|
path: 'src/app.ts',
|
||||||
|
status: 'modified',
|
||||||
|
additions: 2,
|
||||||
|
deletions: 1,
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
previewTabsBySession: {
|
previewTabsBySession: {
|
||||||
@ -1156,11 +1172,10 @@ describe('WorkspacePanel', () => {
|
|||||||
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||||
|
await flushReactWork()
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
||||||
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-preview-hidden-tree', '')
|
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
||||||
})
|
|
||||||
expect(view.getByText('src')).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses theme tokens for the panel, preview header, and code surface in dark mode', async () => {
|
it('uses theme tokens for the panel, preview header, and code surface in dark mode', async () => {
|
||||||
@ -1268,7 +1283,8 @@ describe('WorkspacePanel', () => {
|
|||||||
|
|
||||||
expect(highlightedCode).toContain('+line 1')
|
expect(highlightedCode).toContain('+line 1')
|
||||||
expect(highlightedCode).toContain('+line 1999')
|
expect(highlightedCode).toContain('+line 1999')
|
||||||
expect(highlightedCode).not.toContain('+line 2000')
|
expect(highlightedCode).toContain('+line 2000')
|
||||||
|
expect(highlightedCode).not.toContain('+line 2001')
|
||||||
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -1325,7 +1341,10 @@ describe('WorkspacePanel', () => {
|
|||||||
|
|
||||||
expect(firstRow?.className).toContain('w-max')
|
expect(firstRow?.className).toContain('w-max')
|
||||||
expect(firstRow?.className).toContain('min-w-full')
|
expect(firstRow?.className).toContain('min-w-full')
|
||||||
expect(firstRow?.className).toContain('grid-cols-[46px_46px_30px_20px_minmax(max-content,1fr)]')
|
expect((firstRow as HTMLElement | null)?.style.gridTemplateColumns).toBe(
|
||||||
|
'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)',
|
||||||
|
)
|
||||||
|
expect(firstRow?.querySelector('[data-diff-number-gutter]')).toBeTruthy()
|
||||||
expect(diffSurface.textContent).toContain(longDiffLine)
|
expect(diffSurface.textContent).toContain(longDiffLine)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
||||||
import { CircleAlert, Code2, File as FileIcon, FileText, FolderOpen, GitCompareArrows, Image as ImageIcon, MessageCircle, PanelRightClose, PanelRightOpen, RefreshCw, Search, Settings2, X, type LucideIcon } from 'lucide-react'
|
import { CircleAlert, Code2, File as FileIcon, FileText, FolderOpen, Image as ImageIcon, MessageCircle, PanelRightClose, PanelRightOpen, RefreshCw, Search, Settings2, X, type LucideIcon } from 'lucide-react'
|
||||||
import { Highlight } from 'prism-react-renderer'
|
import { Highlight } from 'prism-react-renderer'
|
||||||
import type {
|
import type {
|
||||||
WorkspaceChangedFile,
|
WorkspaceChangedFile,
|
||||||
@ -823,8 +823,8 @@ function ChangedFileRow({
|
|||||||
data-workspace-file-row=""
|
data-workspace-file-row=""
|
||||||
data-workspace-file-path={file.path}
|
data-workspace-file-path={file.path}
|
||||||
title={file.path}
|
title={file.path}
|
||||||
className={`group mx-2 flex w-[calc(100%-16px)] items-center gap-2 rounded-[6px] px-2.5 text-left transition-[background-color,transform] duration-200 ease-out active:scale-[0.99] ${
|
className={`group mx-2 flex w-[calc(100%-16px)] items-center gap-1.5 rounded-[5px] px-2 text-left transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] ${
|
||||||
file.oldPath ? 'min-h-[52px] py-1.5' : 'h-10'
|
file.oldPath ? 'min-h-11 py-1' : 'h-[30px]'
|
||||||
} ${
|
} ${
|
||||||
active
|
active
|
||||||
? 'bg-[var(--color-info-container)] shadow-[inset_3px_0_0_var(--color-info)]'
|
? 'bg-[var(--color-info-container)] shadow-[inset_3px_0_0_var(--color-info)]'
|
||||||
@ -832,17 +832,17 @@ function ChangedFileRow({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-flex h-6 min-w-6 shrink-0 items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-secondary)]"
|
className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-secondary)]"
|
||||||
title={identity.languageLabel}
|
title={identity.languageLabel}
|
||||||
>
|
>
|
||||||
<IdentityIcon aria-hidden="true" size={14} strokeWidth={1.8} />
|
<IdentityIcon aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
<div className="flex min-w-0 items-baseline">
|
||||||
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{fileName}</span>
|
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{fileName}</span>
|
||||||
<span className="shrink-0 text-[9px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
<span className="sr-only">
|
||||||
{identity.shortLabel}
|
{identity.shortLabel}
|
||||||
<span className="sr-only"> {identity.languageLabel}</span>
|
<span> {identity.languageLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{file.oldPath && (
|
{file.oldPath && (
|
||||||
@ -851,7 +851,7 @@ function ChangedFileRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right font-[var(--font-mono)] text-[11px] leading-4">
|
<div className="shrink-0 text-right font-[var(--font-mono)] text-[10px] leading-4">
|
||||||
<span className="text-[var(--color-success)]">+{file.additions}</span>
|
<span className="text-[var(--color-success)]">+{file.additions}</span>
|
||||||
<span className="ml-1 text-[var(--color-error)]">-{file.deletions}</span>
|
<span className="ml-1 text-[var(--color-error)]">-{file.deletions}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1032,7 +1032,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null
|
previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null
|
||||||
const hasPreviewTabs = previewTabs.length > 0
|
const hasPreviewTabs = previewTabs.length > 0
|
||||||
const isNavigatorVisible = !hasPreviewTabs || isNavigatorOpen
|
const isNavigatorVisible = !hasPreviewTabs || isNavigatorOpen
|
||||||
|
const navigatorView = hasPreviewTabs ? 'changed' : activeView
|
||||||
const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null
|
const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null
|
||||||
|
const activeChangedFile = activePreviewTab
|
||||||
|
? status?.changedFiles.find((file) => file.path === activePreviewTab.path) ?? null
|
||||||
|
: null
|
||||||
const filteredChangedFiles = useMemo(
|
const filteredChangedFiles = useMemo(
|
||||||
() => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)),
|
() => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)),
|
||||||
[normalizedFilterQuery, status?.changedFiles],
|
[normalizedFilterQuery, status?.changedFiles],
|
||||||
@ -1041,27 +1045,17 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
() => groupChangedFiles(filteredChangedFiles),
|
() => groupChangedFiles(filteredChangedFiles),
|
||||||
[filteredChangedFiles],
|
[filteredChangedFiles],
|
||||||
)
|
)
|
||||||
const changeTotals = useMemo(
|
|
||||||
() => (status?.changedFiles ?? []).reduce(
|
|
||||||
(totals, file) => ({
|
|
||||||
additions: totals.additions + file.additions,
|
|
||||||
deletions: totals.deletions + file.deletions,
|
|
||||||
}),
|
|
||||||
{ additions: 0, deletions: 0 },
|
|
||||||
),
|
|
||||||
[status?.changedFiles],
|
|
||||||
)
|
|
||||||
const filteredRootEntries = useMemo(
|
const filteredRootEntries = useMemo(
|
||||||
() => rootTree?.state === 'ok'
|
() => rootTree?.state === 'ok'
|
||||||
? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath))
|
? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath))
|
||||||
: [],
|
: [],
|
||||||
[normalizedFilterQuery, rootTree, treeByPath],
|
[normalizedFilterQuery, rootTree, treeByPath],
|
||||||
)
|
)
|
||||||
const visibleEntryCount = activeView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
|
const visibleEntryCount = navigatorView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
|
||||||
const totalEntryCount = activeView === 'changed'
|
const totalEntryCount = navigatorView === 'changed'
|
||||||
? status?.changedFiles.length ?? 0
|
? status?.changedFiles.length ?? 0
|
||||||
: rootTree?.state === 'ok' ? rootTree.entries.length : 0
|
: rootTree?.state === 'ok' ? rootTree.entries.length : 0
|
||||||
const filterSummary = activeView === 'changed'
|
const filterSummary = navigatorView === 'changed'
|
||||||
? normalizedFilterQuery
|
? normalizedFilterQuery
|
||||||
? t('workspace.filteredFilesCount', { visible: visibleEntryCount, total: totalEntryCount })
|
? t('workspace.filteredFilesCount', { visible: visibleEntryCount, total: totalEntryCount })
|
||||||
: t('workspace.filesCount', { count: totalEntryCount })
|
: t('workspace.filesCount', { count: totalEntryCount })
|
||||||
@ -1100,9 +1094,9 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
}, [chatState, loadStatus, sessionId, shouldRender, statusLoading])
|
}, [chatState, loadStatus, sessionId, shouldRender, statusLoading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldRender || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
if (!shouldRender || !isNavigatorVisible || navigatorView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
||||||
void loadTree(sessionId, '')
|
void loadTree(sessionId, '')
|
||||||
}, [activeView, isNavigatorVisible, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
|
}, [isNavigatorVisible, loadTree, navigatorView, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewTabContextMenu && !fileContextMenu) return
|
if (!previewTabContextMenu && !fileContextMenu) return
|
||||||
@ -1137,7 +1131,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
if (activePreviewTab) {
|
if (activePreviewTab) {
|
||||||
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
|
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
|
||||||
}
|
}
|
||||||
if (activeView === 'all') {
|
if (navigatorView === 'all') {
|
||||||
void loadTree(sessionId, '')
|
void loadTree(sessionId, '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1370,7 +1364,6 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindLabel = getPreviewKindLabel(t, activePreviewTab.kind)
|
|
||||||
const state = activePreviewTab.state ?? 'loading'
|
const state = activePreviewTab.state ?? 'loading'
|
||||||
const refreshErrorMessage = activePreviewError
|
const refreshErrorMessage = activePreviewError
|
||||||
|| (activePreviewRefreshState ? getInlineStateMessage(t, activePreviewRefreshState) : null)
|
|| (activePreviewRefreshState ? getInlineStateMessage(t, activePreviewRefreshState) : null)
|
||||||
@ -1383,30 +1376,44 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="workspace-preview-header"
|
data-testid="workspace-preview-header"
|
||||||
className="flex h-11 shrink-0 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-4 text-[12px]"
|
className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-3 text-[12px]"
|
||||||
>
|
>
|
||||||
<FileText size={15} strokeWidth={1.8} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
<FileText size={15} strokeWidth={1.8} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">{activePreviewTab.path}</span>
|
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">{activePreviewTab.path}</span>
|
||||||
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
{activeChangedFile && (
|
||||||
{kindLabel}
|
<span className="flex shrink-0 items-center gap-1.5 font-[var(--font-mono)] text-[11px] tabular-nums">
|
||||||
|
<span className="text-[var(--color-success)]">+{activeChangedFile.additions}</span>
|
||||||
|
<span className="text-[var(--color-error)]">-{activeChangedFile.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('workspace.addToChat')}
|
aria-label={t('workspace.addToChat')}
|
||||||
onClick={() => addWorkspacePathToChat(activePreviewTab.path)}
|
onClick={() => addWorkspacePathToChat(activePreviewTab.path)}
|
||||||
className="ml-auto inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-[color,background-color,transform] duration-200 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-[color,background-color,transform] duration-200 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<MessageCircle size={14} strokeWidth={1.8} aria-hidden="true" />
|
<MessageCircle size={14} strokeWidth={1.8} aria-hidden="true" />
|
||||||
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
|
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<ToolbarIconButton Icon={RefreshCw} label={t('workspace.refresh')} onClick={handleRefresh} />
|
||||||
|
<ToolbarIconButton
|
||||||
|
Icon={isNavigatorVisible ? PanelRightClose : PanelRightOpen}
|
||||||
|
label={isNavigatorVisible ? t('workspace.hideNavigator') : t('workspace.showNavigator')}
|
||||||
|
onClick={() => setIsNavigatorOpen((open) => !open)}
|
||||||
|
/>
|
||||||
|
{!embedded && (
|
||||||
|
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
|
||||||
|
)}
|
||||||
{activePreviewLoading && state === 'ok' && (
|
{activePreviewLoading && state === 'ok' && (
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
size={13}
|
size={13}
|
||||||
className="shrink-0 animate-spin text-[var(--color-text-tertiary)]"
|
className="mx-1 shrink-0 animate-spin text-[var(--color-text-tertiary)]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
|
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
|
||||||
<div
|
<div
|
||||||
@ -1435,6 +1442,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
<WorkspaceDiffSurface
|
<WorkspaceDiffSurface
|
||||||
value={activePreviewTab.diff ?? ''}
|
value={activePreviewTab.diff ?? ''}
|
||||||
path={activePreviewTab.path}
|
path={activePreviewTab.path}
|
||||||
|
hideSingleFileHeader
|
||||||
onAddComment={(selection, note) => addDiffCommentToChat(activePreviewTab.path, selection, note)}
|
onAddComment={(selection, note) => addDiffCommentToChat(activePreviewTab.path, selection, note)}
|
||||||
/>
|
/>
|
||||||
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
|
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
|
||||||
@ -1584,47 +1592,12 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
}
|
}
|
||||||
style={embedded ? undefined : { width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
|
style={embedded ? undefined : { width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
|
||||||
>
|
>
|
||||||
<header
|
|
||||||
data-testid="workspace-review-toolbar"
|
|
||||||
aria-label={t('workspace.reviewWorkspace')}
|
|
||||||
className="flex h-12 shrink-0 items-center gap-2.5 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-4"
|
|
||||||
>
|
|
||||||
<GitCompareArrows size={16} strokeWidth={1.8} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
|
||||||
<span className="min-w-0 truncate text-[13px] font-semibold text-[var(--color-text-primary)]">
|
|
||||||
{status?.repoName || t('workbench.tabTitle')}
|
|
||||||
</span>
|
|
||||||
{status?.branch && (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true" className="h-4 w-px bg-[var(--color-text-primary)]/10" />
|
|
||||||
<span className="max-w-32 truncate text-[11px] text-[var(--color-text-tertiary)]">{status.branch}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex shrink-0 items-center gap-2 font-[var(--font-mono)] text-[11px] tabular-nums">
|
|
||||||
<span className="text-[var(--color-success)]">+{changeTotals.additions}</span>
|
|
||||||
<span className="text-[var(--color-error)]">-{changeTotals.deletions}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
|
||||||
<ToolbarIconButton Icon={RefreshCw} label={t('workspace.refresh')} onClick={handleRefresh} />
|
|
||||||
{hasPreviewTabs && (
|
|
||||||
<ToolbarIconButton
|
|
||||||
Icon={isNavigatorVisible ? PanelRightClose : PanelRightOpen}
|
|
||||||
label={isNavigatorVisible ? t('workspace.hideNavigator') : t('workspace.showNavigator')}
|
|
||||||
onClick={() => setIsNavigatorOpen((open) => !open)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!embedded && (
|
|
||||||
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="workspace-review-layout"
|
data-testid="workspace-review-layout"
|
||||||
className={`relative grid min-h-0 flex-1 ${hasPreviewTabs && isNavigatorVisible && forceVisible ? 'grid-cols-[minmax(0,1fr)_280px]' : 'grid-cols-1'}`}
|
className={`relative grid min-h-0 flex-1 overflow-hidden ${hasPreviewTabs && isNavigatorVisible && forceVisible ? 'grid-cols-[minmax(0,1fr)_280px]' : 'grid-cols-1'}`}
|
||||||
>
|
>
|
||||||
{hasPreviewTabs && (
|
{hasPreviewTabs && (
|
||||||
<div className="flex min-w-0 flex-col bg-[var(--color-surface)]">
|
<div data-testid="workspace-preview-column" className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||||
{previewTabs.length > 1 ? renderPreviewTabs() : null}
|
{previewTabs.length > 1 ? renderPreviewTabs() : null}
|
||||||
{renderPreviewContent()}
|
{renderPreviewContent()}
|
||||||
</div>
|
</div>
|
||||||
@ -1635,7 +1608,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
data-testid="workspace-file-navigator"
|
data-testid="workspace-file-navigator"
|
||||||
className={`${hasPreviewTabs ? 'border-l border-[var(--color-text-primary)]/10' : ''} ${hasPreviewTabs && !forceVisible ? 'absolute inset-y-0 right-0 z-20 w-[min(280px,100%)] shadow-[-12px_0_28px_rgba(15,23,42,0.08)]' : ''} flex min-h-0 flex-col bg-[var(--color-surface)]`}
|
className={`${hasPreviewTabs ? 'border-l border-[var(--color-text-primary)]/10' : ''} ${hasPreviewTabs && !forceVisible ? 'absolute inset-y-0 right-0 z-20 w-[min(280px,100%)] shadow-[-12px_0_28px_rgba(15,23,42,0.08)]' : ''} flex min-h-0 flex-col bg-[var(--color-surface)]`}
|
||||||
>
|
>
|
||||||
<div className="flex h-11 shrink-0 items-center gap-1.5 border-b border-[var(--color-text-primary)]/10 px-3.5">
|
{!hasPreviewTabs && (
|
||||||
|
<header
|
||||||
|
data-testid="workspace-file-navigator-header"
|
||||||
|
className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-text-primary)]/10 px-3"
|
||||||
|
>
|
||||||
<div className="relative min-w-0">
|
<div className="relative min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1679,12 +1656,25 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!hasPreviewTabs && (
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||||
|
<ToolbarIconButton Icon={RefreshCw} label={t('workspace.refresh')} onClick={handleRefresh} />
|
||||||
|
{!embedded && (
|
||||||
|
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
<WorkspaceFilterInput value={filterQuery} onChange={setFilterQuery} summary={filterSummary} />
|
<WorkspaceFilterInput
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={setFilterQuery}
|
||||||
|
summary={normalizedFilterQuery ? filterSummary : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto py-1.5">
|
<div className="min-h-0 flex-1 overflow-auto py-1.5">
|
||||||
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
|
{navigatorView === 'changed' ? renderChangedView() : renderAllFilesView()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user