mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +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()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const onAddComment = vi.fn()
|
||||
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('data-range-edge', 'end')
|
||||
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).not.toContain('bg-[var(--color-diff-added-bg)]')
|
||||
expect(screen.getByTestId('workspace-code').className).toContain('text-[13px]')
|
||||
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
||||
expect(editor.closest('[data-diff-editor]')?.className).toContain('rounded-[10px]')
|
||||
expect(editor.className).toContain('var(--color-info)')
|
||||
const editorContainer = editor.closest('[data-diff-editor]')
|
||||
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')
|
||||
const focusedGutter = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
|
||||
expect(focusedGutter).toHaveAttribute('data-selection-focus', 'true')
|
||||
@ -257,14 +314,14 @@ describe('WorkspaceDiffSurface', () => {
|
||||
|
||||
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.getByText('Showing first 5 of 9 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(<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: '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' }), {
|
||||
target: { value: 'Keep this collapsed draft' },
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
@ -110,6 +111,7 @@ export interface WorkspaceDiffSurfaceProps {
|
||||
path: string
|
||||
className?: string
|
||||
lineLimit?: number
|
||||
hideSingleFileHeader?: boolean
|
||||
onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
|
||||
}
|
||||
|
||||
@ -136,6 +138,14 @@ function rowTone(row: WorkspaceDiffRow) {
|
||||
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) {
|
||||
if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]'
|
||||
if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]'
|
||||
@ -148,19 +158,40 @@ function codeTone(row: WorkspaceDiffRow) {
|
||||
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({
|
||||
value,
|
||||
path,
|
||||
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
||||
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
hideSingleFileHeader = false,
|
||||
onAddComment,
|
||||
}: WorkspaceDiffSurfaceProps) {
|
||||
const t = useTranslation()
|
||||
const files = useMemo(() => parseWorkspaceDiff(value), [value])
|
||||
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(
|
||||
() => files.flatMap((file) => [`${file.id}-header`, ...file.rows.map((row) => row.id)]),
|
||||
[files],
|
||||
() => files.flatMap((file) => [
|
||||
...(showFileHeaders ? [`${file.id}-header`] : []),
|
||||
...file.rows.filter((row) => !isStructuralMetadata(row)).map((row) => row.id),
|
||||
]),
|
||||
[files, showFileHeaders],
|
||||
)
|
||||
const [review, setReview] = useState<ReviewState>(emptyReviewState)
|
||||
const [status, setStatus] = useState<ReviewStatus>(null)
|
||||
@ -402,21 +433,26 @@ export function WorkspaceDiffSurface({
|
||||
const renderEditor = () => review.selection && (
|
||||
<div
|
||||
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 role="status" aria-live="polite" className="mb-1.5 text-[11px] text-[var(--color-warning)]">
|
||||
{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)]">
|
||||
<div className="flex min-h-10 items-center gap-2 px-3 pt-2.5">
|
||||
<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)]">
|
||||
<MessageSquare aria-hidden="true" size={14} />
|
||||
</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}`}
|
||||
</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
|
||||
ref={editorRef}
|
||||
aria-label={t('workspace.diffReview.editorLabel')}
|
||||
@ -424,10 +460,10 @@ export function WorkspaceDiffSurface({
|
||||
placeholder={t('workspace.commentPlaceholder')}
|
||||
onChange={(event) => setReview((current) => ({ ...current, draft: event.target.value }))}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
rows={3}
|
||||
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)]"
|
||||
rows={2}
|
||||
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
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
@ -450,18 +486,19 @@ export function WorkspaceDiffSurface({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative min-w-max pb-3">
|
||||
<div data-testid="workspace-diff-scroll" className={className} style={{ containerType: 'inline-size' }}>
|
||||
<div data-testid="workspace-diff-content" className="relative min-w-full w-max pb-3">
|
||||
<div
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
role="grid"
|
||||
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) => {
|
||||
const headerVisible = visibleItemIds.has(`${file.id}-header`)
|
||||
const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id))
|
||||
const headerVisible = showFileHeaders && visibleItemIds.has(`${file.id}-header`)
|
||||
const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id) && !isStructuralMetadata(row))
|
||||
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'
|
||||
@ -512,61 +549,72 @@ export function WorkspaceDiffSurface({
|
||||
aria-selected={selected}
|
||||
data-diff-row-id={row.id}
|
||||
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)
|
||||
}`}
|
||||
style={{ gridTemplateColumns: 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)' }}
|
||||
>
|
||||
{selected && (
|
||||
<span
|
||||
data-diff-selection-rail=""
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-y-0 left-0 w-[3px] bg-[var(--color-info)] ${
|
||||
rangeEdge === 'single' ? 'rounded-sm' : rangeEdge === 'start' ? 'rounded-t-sm' : rangeEdge === 'end' ? 'rounded-b-sm' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="select-none self-center text-right text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{row.oldLine ?? ''}
|
||||
</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 && (
|
||||
<button
|
||||
ref={(element) => {
|
||||
if (element) buttonRefs.current.set(row.id, element)
|
||||
else buttonRefs.current.delete(row.id)
|
||||
}}
|
||||
type="button"
|
||||
aria-label={t('workspace.diffReview.commentLineAria', {
|
||||
path,
|
||||
side: sideLabel(row.side),
|
||||
line,
|
||||
})}
|
||||
aria-pressed={selected}
|
||||
data-selection-focus={selectionFocus ? 'true' : undefined}
|
||||
tabIndex={row.id === rovingId ? 0 : -1}
|
||||
onClick={(event) => handleRowClick(event, row)}
|
||||
onFocus={() => setRovingId(row.id)}
|
||||
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)] ${
|
||||
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'
|
||||
<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 && (
|
||||
<span
|
||||
data-diff-selection-rail=""
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-y-0 left-0 w-[3px] bg-[var(--color-info)] ${
|
||||
rangeEdge === 'single' ? 'rounded-sm' : rangeEdge === 'start' ? 'rounded-t-sm' : rangeEdge === 'end' ? 'rounded-b-sm' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
data-diff-line-number=""
|
||||
className={`transition-opacity duration-100 group-hover:opacity-0 group-focus-within:opacity-0 ${selectionFocus ? 'opacity-0' : ''}`}
|
||||
>
|
||||
{line ?? ''}
|
||||
</span>
|
||||
{row.selectable && row.side && line !== null && (
|
||||
<span
|
||||
data-diff-gutter-utility-slot=""
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-end"
|
||||
>
|
||||
{selected ? <MessageSquare aria-hidden="true" size={14} /> : <Plus aria-hidden="true" size={14} />}
|
||||
</button>
|
||||
<button
|
||||
ref={(element) => {
|
||||
if (element) buttonRefs.current.set(row.id, element)
|
||||
else buttonRefs.current.delete(row.id)
|
||||
}}
|
||||
type="button"
|
||||
aria-label={t('workspace.diffReview.commentLineAria', {
|
||||
path,
|
||||
side: sideLabel(row.side),
|
||||
line,
|
||||
})}
|
||||
aria-pressed={selected}
|
||||
data-selection-focus={selectionFocus ? 'true' : undefined}
|
||||
tabIndex={row.id === rovingId ? 0 : -1}
|
||||
onClick={(event) => handleRowClick(event, row)}
|
||||
onFocus={() => setRovingId(row.id)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, row)}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{selected ? <MessageSquare aria-hidden="true" size={12} /> : <Plus aria-hidden="true" size={13} />}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`select-none self-center text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
|
||||
<span
|
||||
data-row-text={row.text}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
className={`whitespace-pre self-center pr-6 ${codeTone(row)}`}
|
||||
className="whitespace-pre self-center pr-6"
|
||||
>
|
||||
{row.selectable && row.text && !usePlainLargePreview
|
||||
? <InlineHighlightedCode value={row.text} language={language} />
|
||||
: row.text || ' '}
|
||||
<span className={`inline-block w-[2ch] select-none text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
|
||||
<span className={codeTone(row)}>
|
||||
{row.selectable && row.text && !usePlainLargePreview
|
||||
? <InlineHighlightedCode value={row.text} language={language} />
|
||||
: row.text || ' '}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{review.selection?.endId === row.id ? renderEditor() : null}
|
||||
|
||||
@ -417,18 +417,27 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
|
||||
})
|
||||
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('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()
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
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-review-layout').className).toContain('grid-cols-1')
|
||||
const expandedPanel = view.getByTestId('workspace-panel')
|
||||
expect(expandedPanel.style.width).toBe('860px')
|
||||
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
|
||||
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
|
||||
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@ -482,7 +491,7 @@ describe('WorkspacePanel', () => {
|
||||
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'
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
@ -509,16 +518,16 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
const view = await renderPanel(sessionId)
|
||||
|
||||
const toolbar = view.getByTestId('workspace-review-toolbar')
|
||||
expect(toolbar.getAttribute('aria-label')).toBe('Review workspace')
|
||||
expect(toolbar.tagName).toBe('HEADER')
|
||||
expect(toolbar.textContent).toContain('claude-code-haha')
|
||||
expect(toolbar.textContent).toContain('main')
|
||||
expect(toolbar.textContent).toContain('+15')
|
||||
expect(toolbar.textContent).toContain('-3')
|
||||
expect(view.queryByTestId('workspace-review-toolbar')).toBeNull()
|
||||
const navigatorHeader = view.getByTestId('workspace-file-navigator-header')
|
||||
expect(navigatorHeader.tagName).toBe('HEADER')
|
||||
expect(navigatorHeader.textContent).toContain('Changed files')
|
||||
expect(view.getByRole('button', { name: /Refresh/ })).toBeTruthy()
|
||||
expect(view.queryByText('claude-code-haha')).toBeNull()
|
||||
expect(view.queryByText('main')).toBeNull()
|
||||
|
||||
const filter = view.getByPlaceholderText('Filter files...')
|
||||
expect(view.getByText('3 files')).toBeTruthy()
|
||||
expect(view.queryByText('3 files')).toBeNull()
|
||||
fireEvent.change(filter, { target: { value: 'theme' } })
|
||||
|
||||
expect(view.getByText('1 of 3 files')).toBeTruthy()
|
||||
@ -566,6 +575,9 @@ describe('WorkspacePanel', () => {
|
||||
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('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.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
|
||||
})
|
||||
@ -644,7 +656,7 @@ describe('WorkspacePanel', () => {
|
||||
const oldPath = view.getByText('desktop/src/components/workspace/LegacyWorkspacePanelWithALongName.tsx')
|
||||
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(view.getByText('next.ts')).toBeTruthy()
|
||||
})
|
||||
@ -844,7 +856,7 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
|
||||
expect(await view.findByText('src')).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()
|
||||
|
||||
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'readme' } })
|
||||
@ -965,7 +977,6 @@ describe('WorkspacePanel', () => {
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
@ -1097,13 +1108,13 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
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.container.querySelector('[data-workspace-file-path="src/app.ts"]')).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({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
@ -1128,7 +1139,12 @@ describe('WorkspacePanel', () => {
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
changedFiles: [{
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
}],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
@ -1156,11 +1172,10 @@ describe('WorkspacePanel', () => {
|
||||
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
||||
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
await flushReactWork()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-preview-hidden-tree', '')
|
||||
})
|
||||
expect(view.getByText('src')).toBeTruthy()
|
||||
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
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 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 waitFor(() => {
|
||||
@ -1325,7 +1341,10 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
expect(firstRow?.className).toContain('w-max')
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 type {
|
||||
WorkspaceChangedFile,
|
||||
@ -823,8 +823,8 @@ function ChangedFileRow({
|
||||
data-workspace-file-row=""
|
||||
data-workspace-file-path={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] ${
|
||||
file.oldPath ? 'min-h-[52px] py-1.5' : 'h-10'
|
||||
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-11 py-1' : 'h-[30px]'
|
||||
} ${
|
||||
active
|
||||
? 'bg-[var(--color-info-container)] shadow-[inset_3px_0_0_var(--color-info)]'
|
||||
@ -832,17 +832,17 @@ function ChangedFileRow({
|
||||
}`}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<IdentityIcon aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<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="shrink-0 text-[9px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
<span className="sr-only">
|
||||
{identity.shortLabel}
|
||||
<span className="sr-only"> {identity.languageLabel}</span>
|
||||
<span> {identity.languageLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
{file.oldPath && (
|
||||
@ -851,7 +851,7 @@ function ChangedFileRow({
|
||||
</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="ml-1 text-[var(--color-error)]">-{file.deletions}</span>
|
||||
</div>
|
||||
@ -1032,7 +1032,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null
|
||||
const hasPreviewTabs = previewTabs.length > 0
|
||||
const isNavigatorVisible = !hasPreviewTabs || isNavigatorOpen
|
||||
const navigatorView = hasPreviewTabs ? 'changed' : activeView
|
||||
const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null
|
||||
const activeChangedFile = activePreviewTab
|
||||
? status?.changedFiles.find((file) => file.path === activePreviewTab.path) ?? null
|
||||
: null
|
||||
const filteredChangedFiles = useMemo(
|
||||
() => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)),
|
||||
[normalizedFilterQuery, status?.changedFiles],
|
||||
@ -1041,27 +1045,17 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
() => groupChangedFiles(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(
|
||||
() => rootTree?.state === 'ok'
|
||||
? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath))
|
||||
: [],
|
||||
[normalizedFilterQuery, rootTree, treeByPath],
|
||||
)
|
||||
const visibleEntryCount = activeView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
|
||||
const totalEntryCount = activeView === 'changed'
|
||||
const visibleEntryCount = navigatorView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
|
||||
const totalEntryCount = navigatorView === 'changed'
|
||||
? status?.changedFiles.length ?? 0
|
||||
: rootTree?.state === 'ok' ? rootTree.entries.length : 0
|
||||
const filterSummary = activeView === 'changed'
|
||||
const filterSummary = navigatorView === 'changed'
|
||||
? normalizedFilterQuery
|
||||
? t('workspace.filteredFilesCount', { visible: visibleEntryCount, total: totalEntryCount })
|
||||
: t('workspace.filesCount', { count: totalEntryCount })
|
||||
@ -1100,9 +1094,9 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
}, [chatState, loadStatus, sessionId, shouldRender, statusLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
||||
if (!shouldRender || !isNavigatorVisible || navigatorView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
||||
void loadTree(sessionId, '')
|
||||
}, [activeView, isNavigatorVisible, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
|
||||
}, [isNavigatorVisible, loadTree, navigatorView, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewTabContextMenu && !fileContextMenu) return
|
||||
@ -1137,7 +1131,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
if (activePreviewTab) {
|
||||
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
|
||||
}
|
||||
if (activeView === 'all') {
|
||||
if (navigatorView === 'all') {
|
||||
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 refreshErrorMessage = activePreviewError
|
||||
|| (activePreviewRefreshState ? getInlineStateMessage(t, activePreviewRefreshState) : null)
|
||||
@ -1383,29 +1376,43 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
>
|
||||
<div
|
||||
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)]" />
|
||||
<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)]">
|
||||
{kindLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.addToChat')}
|
||||
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]"
|
||||
>
|
||||
<MessageCircle size={14} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
|
||||
</button>
|
||||
{activePreviewLoading && state === 'ok' && (
|
||||
<RefreshCw
|
||||
size={13}
|
||||
className="shrink-0 animate-spin text-[var(--color-text-tertiary)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{activeChangedFile && (
|
||||
<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>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.addToChat')}
|
||||
onClick={() => addWorkspacePathToChat(activePreviewTab.path)}
|
||||
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" />
|
||||
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
|
||||
</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' && (
|
||||
<RefreshCw
|
||||
size={13}
|
||||
className="mx-1 shrink-0 animate-spin text-[var(--color-text-tertiary)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
|
||||
@ -1435,6 +1442,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
<WorkspaceDiffSurface
|
||||
value={activePreviewTab.diff ?? ''}
|
||||
path={activePreviewTab.path}
|
||||
hideSingleFileHeader
|
||||
onAddComment={(selection, note) => addDiffCommentToChat(activePreviewTab.path, selection, note)}
|
||||
/>
|
||||
) : 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 }}
|
||||
>
|
||||
<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
|
||||
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 && (
|
||||
<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}
|
||||
{renderPreviewContent()}
|
||||
</div>
|
||||
@ -1635,7 +1608,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
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)]`}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@ -1679,12 +1656,25 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
</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>
|
||||
)}
|
||||
</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">
|
||||
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
|
||||
{navigatorView === 'changed' ? renderChangedView() : renderAllFilesView()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user