mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat(desktop): add inline diff review flow #1004
This commit is contained in:
parent
69c4296a3f
commit
6c6d3c51e3
@ -2,10 +2,43 @@
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { AttachmentGallery } from './AttachmentGallery'
|
||||
|
||||
describe('AttachmentGallery', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('renders diff comments as note-first composer cards with side-aware locations', () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
attachments={[{
|
||||
id: 'diff-comment-1',
|
||||
type: 'file',
|
||||
name: 'a.ts',
|
||||
path: 'src/a.ts',
|
||||
lineStart: 11,
|
||||
lineEnd: 12,
|
||||
diffSide: 'new',
|
||||
hunkId: 'hunk-1',
|
||||
note: 'Use a shared helper',
|
||||
quote: 'const result = buildResult()\nreturn result',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const card = view.getByTestId('diff-comment-card')
|
||||
expect(card.textContent).toContain('src/a.ts · new L11-L12')
|
||||
expect(card.textContent).toContain('Use a shared helper')
|
||||
expect(card.textContent).toContain('const result = buildResult() return result')
|
||||
expect(card.textContent?.indexOf('Use a shared helper')).toBeLessThan(
|
||||
card.textContent?.indexOf('const result = buildResult()') ?? -1,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a compact quote preview for selected workspace text', () => {
|
||||
render(
|
||||
<AttachmentGallery
|
||||
@ -90,4 +123,26 @@ describe('AttachmentGallery', () => {
|
||||
expect(tooltip).toHaveTextContent('这个标题更轻一点')
|
||||
expect(tooltip.className).toContain('group-hover/selection:visible')
|
||||
})
|
||||
|
||||
it('localizes diff sides and remove actions in Chinese', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
onRemove={vi.fn()}
|
||||
attachments={[{
|
||||
id: 'diff-comment-zh',
|
||||
type: 'file',
|
||||
name: 'a.ts',
|
||||
path: 'src/a.ts',
|
||||
lineStart: 11,
|
||||
diffSide: 'new',
|
||||
note: '使用共享辅助函数',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(view.getByTestId('diff-comment-card')).toHaveTextContent('src/a.ts · 新 L11')
|
||||
expect(view.getByRole('button', { name: '移除 a.ts' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MessageSquare, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
|
||||
export type AttachmentPreview = {
|
||||
@ -11,6 +13,8 @@ export type AttachmentPreview = {
|
||||
isDirectory?: boolean
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
diffSide?: 'old' | 'new'
|
||||
hunkId?: string
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
@ -22,6 +26,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
|
||||
const t = useTranslation()
|
||||
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
|
||||
|
||||
const images = useMemo(
|
||||
@ -120,7 +125,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id!)}
|
||||
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
aria-label={t('attachments.remove', { name: attachment.name })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -129,6 +134,57 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
)
|
||||
}
|
||||
|
||||
if (attachment.diffSide) {
|
||||
const lineRange = attachment.lineStart
|
||||
? `L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
|
||||
: ''
|
||||
const location = [
|
||||
attachment.path || attachment.name,
|
||||
'·',
|
||||
t(`workspace.diffReview.side.${attachment.diffSide}`),
|
||||
lineRange,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const note = attachment.note?.trim()
|
||||
const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
data-testid="diff-comment-card"
|
||||
className="group/diff-comment flex max-w-[min(420px,100%)] min-w-[240px] items-start gap-2 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-2 text-left shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
|
||||
>
|
||||
<MessageSquare aria-hidden="true" size={15} className="mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[11px] font-medium text-[var(--color-text-tertiary)]">
|
||||
{location}
|
||||
</span>
|
||||
{note && (
|
||||
<span className="mt-0.5 block text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
|
||||
{note}
|
||||
</span>
|
||||
)}
|
||||
{quotePreview && (
|
||||
<span className="mt-0.5 block truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
||||
{quotePreview}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{onRemove && attachment.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id!)}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label={t('attachments.remove', { name: attachment.name })}
|
||||
>
|
||||
<X aria-hidden="true" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const lineLabel = attachment.lineStart
|
||||
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
|
||||
: ''
|
||||
@ -164,7 +220,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id!)}
|
||||
className={`${hasQuotePreview ? 'mt-0.5' : 'ml-0.5'} flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]`}
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
aria-label={t('attachments.remove', { name: attachment.name })}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[17px]">close</span>
|
||||
</button>
|
||||
|
||||
@ -224,6 +224,35 @@ describe('ChatInput file mentions', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('passes diff metadata to the composer card and clears the reference after send', async () => {
|
||||
act(() => {
|
||||
useWorkspaceChatContextStore.getState().addReference(sessionId, {
|
||||
kind: 'code-comment',
|
||||
path: 'src/a.ts',
|
||||
absolutePath: '/repo/src/a.ts',
|
||||
name: 'a.ts',
|
||||
lineStart: 11,
|
||||
lineEnd: 12,
|
||||
diffSide: 'new',
|
||||
hunkId: 'hunk-1',
|
||||
note: 'Use a shared helper',
|
||||
quote: 'const result = buildResult()\nreturn result',
|
||||
})
|
||||
})
|
||||
|
||||
render(<ChatInput compact />)
|
||||
|
||||
expect(screen.getByTestId('diff-comment-card')).toHaveTextContent('src/a.ts · new L11-L12')
|
||||
expect(screen.getByTestId('diff-comment-card')).toHaveTextContent('Use a shared helper')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession[sessionId]).toEqual([])
|
||||
})
|
||||
expect(screen.queryByTestId('diff-comment-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps unsent composer drafts isolated when switching between session tabs', async () => {
|
||||
const historySessionId = 'history-session'
|
||||
useTabStore.setState({
|
||||
|
||||
@ -65,6 +65,8 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta
|
||||
isDirectory: reference.isDirectory,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
diffSide: reference.diffSide,
|
||||
hunkId: reference.hunkId,
|
||||
note: reference.note,
|
||||
quote: reference.quote,
|
||||
}
|
||||
@ -974,6 +976,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-input-shell"
|
||||
data-session-id={activeTabId ?? undefined}
|
||||
className={
|
||||
isHeroComposer
|
||||
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
|
||||
|
||||
@ -6,12 +6,13 @@ import { act } from 'react'
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Hoisted mocks (vi.hoisted runs before module evaluation)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } = vi.hoisted(() => {
|
||||
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState } = vi.hoisted(() => {
|
||||
const openPreviewSpy = vi.fn().mockResolvedValue(undefined)
|
||||
const browserOpenSpy = vi.fn()
|
||||
const openTargetSpy = vi.fn().mockResolvedValue(undefined)
|
||||
const ensureTargetsMock = vi.fn().mockResolvedValue(undefined)
|
||||
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock }
|
||||
const panelState = { isOpen: false }
|
||||
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState }
|
||||
})
|
||||
|
||||
// Mock openTargetStore
|
||||
@ -49,10 +50,10 @@ vi.mock('../../stores/browserPanelStore', () => ({
|
||||
// Mock workspacePanelStore
|
||||
vi.mock('../../stores/workspacePanelStore', () => ({
|
||||
useWorkspacePanelStore: Object.assign(
|
||||
(selector: (s: { openPreview: () => Promise<void> }) => unknown) =>
|
||||
selector({ openPreview: openPreviewSpy }),
|
||||
(selector: (s: { openPreview: () => Promise<void>; isPanelOpen: () => boolean }) => unknown) =>
|
||||
selector({ openPreview: openPreviewSpy, isPanelOpen: () => panelState.isOpen }),
|
||||
{
|
||||
getState: vi.fn(() => ({ openPreview: openPreviewSpy })),
|
||||
getState: vi.fn(() => ({ openPreview: openPreviewSpy, isPanelOpen: () => panelState.isOpen })),
|
||||
},
|
||||
),
|
||||
}))
|
||||
@ -109,7 +110,7 @@ function makeCheckpoint(filesChanged: string[]): SessionTurnCheckpoint {
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(filesChanged: string[]) {
|
||||
function renderCard(filesChanged: string[], isLatest = true) {
|
||||
const checkpoint = makeCheckpoint(filesChanged)
|
||||
return render(
|
||||
<CurrentTurnChangeCard
|
||||
@ -118,7 +119,7 @@ function renderCard(filesChanged: string[]) {
|
||||
workDir="/w/proj"
|
||||
error={null}
|
||||
isUndoing={false}
|
||||
isLatest={true}
|
||||
isLatest={isLatest}
|
||||
onUndo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
@ -136,6 +137,7 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
|
||||
vi.clearAllMocks()
|
||||
ensureTargetsMock.mockResolvedValue(undefined)
|
||||
openPreviewSpy.mockResolvedValue(undefined)
|
||||
panelState.isOpen = false
|
||||
})
|
||||
|
||||
it('renders the filename (not just full path) for each file', () => {
|
||||
@ -192,14 +194,14 @@ describe('CurrentTurnChangeCard – row opens the workspace diff', () => {
|
||||
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
|
||||
fireEvent.click(row)
|
||||
// displayPath is the workDir-relative path (matches the workspace file tree)
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff')
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
})
|
||||
|
||||
it('passes the workDir-relative displayPath (not the absolute path) to openPreview', () => {
|
||||
renderCard(['/w/proj/README.md'])
|
||||
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
|
||||
fireEvent.click(row)
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff')
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
})
|
||||
|
||||
it('clicking an outside-workspace html changed file opens the in-app browser via local-file', () => {
|
||||
@ -216,7 +218,7 @@ describe('CurrentTurnChangeCard – row opens the workspace diff', () => {
|
||||
renderCard(['/other/place/notes.txt'])
|
||||
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
|
||||
fireEvent.click(row)
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file')
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
expect(browserOpenSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -264,11 +266,18 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('hides the workspace chevron on rows that already show an open-with button', () => {
|
||||
it('keeps open-with secondary while every row retains its workspace chevron', () => {
|
||||
renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts'])
|
||||
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
|
||||
expect(screen.getAllByText('chevron_right')).toHaveLength(1)
|
||||
const rows = screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
|
||||
expect(rows.every((row) => row.querySelector('.lucide-chevron-right'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the same destination chevron on every changed-file row', () => {
|
||||
const { container } = renderCard(['/w/proj/README.md', '/w/proj/src/main.ts'])
|
||||
|
||||
expect(container.querySelectorAll('.lucide-chevron-right')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clicking README.md open-with opens menu with workspace preview item', async () => {
|
||||
@ -363,6 +372,36 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('CurrentTurnChangeCard – conversation continuity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
panelState.isOpen = false
|
||||
openPreviewSpy.mockImplementation(async () => {
|
||||
panelState.isOpen = true
|
||||
})
|
||||
})
|
||||
|
||||
it('truthfully labels a historical row as opening the current workspace diff', () => {
|
||||
renderCard(['/w/proj/src/main.ts'], false)
|
||||
|
||||
expect(screen.getByText('chat.turnChangesCurrentWorkspaceDiff')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a stable opener id and semantic turn key before opening the diff', () => {
|
||||
renderCard(['/w/proj/src/main.ts'])
|
||||
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
|
||||
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(row.id).toContain('msg-1')
|
||||
expect(row).toHaveAttribute('data-source-turn-key', 'msg-1')
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff', {
|
||||
sourceTurnKey: 'msg-1',
|
||||
sourceElementId: row.id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CurrentTurnChangeCard – collapse long file lists', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react'
|
||||
import type { SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { OpenWithMenu } from '../common/OpenWithMenu'
|
||||
@ -59,7 +59,12 @@ export function CurrentTurnChangeCard({
|
||||
? files.slice(0, COLLAPSED_COUNT)
|
||||
: files
|
||||
|
||||
const openChangedFile = useCallback((fileEntry: ChangedFileEntry) => {
|
||||
const openChangedFile = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
|
||||
const renderItem = event.currentTarget.closest<HTMLElement>('[data-chat-render-item-key]')
|
||||
const origin = {
|
||||
sourceTurnKey: renderItem?.dataset.chatRenderItemKey ?? checkpoint.target.targetUserMessageId,
|
||||
sourceElementId: event.currentTarget.id,
|
||||
}
|
||||
// A changed file outside the workdir (absolute displayPath — e.g. another
|
||||
// drive) has no checkpoint baseline, so a diff is meaningless. Render html in
|
||||
// the in-app browser and everything else as a file preview (served by its
|
||||
@ -69,14 +74,14 @@ export function CurrentTurnChangeCard({
|
||||
useBrowserPanelStore.getState().open(sessionId, localFileUrl(getServerBaseUrl(), fileEntry.apiPath))
|
||||
return
|
||||
}
|
||||
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file')
|
||||
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file', origin)
|
||||
return
|
||||
}
|
||||
// Jump to the right-side workspace and open a diff tab. We pass the workDir-relative
|
||||
// path (same format the workspace file tree passes to openPreview), so the diff tab
|
||||
// is keyed/fetched identically to the tree-driven one.
|
||||
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff')
|
||||
}, [sessionId, files])
|
||||
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff', origin)
|
||||
}, [checkpoint.target.targetUserMessageId, sessionId, files])
|
||||
|
||||
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
|
||||
event.stopPropagation()
|
||||
@ -113,7 +118,7 @@ export function CurrentTurnChangeCard({
|
||||
: t('chat.turnChangesHistoricalCardLabel')
|
||||
const subtitle = isLatest
|
||||
? t('chat.turnChangesLatestSubtitle')
|
||||
: t('chat.turnChangesHistoricalSubtitle')
|
||||
: t('chat.turnChangesCurrentWorkspaceDiff')
|
||||
const undoLabel = isLatest
|
||||
? t('chat.turnChangesLatestUndo')
|
||||
: t('chat.turnChangesHistoricalUndo')
|
||||
@ -165,7 +170,9 @@ export function CurrentTurnChangeCard({
|
||||
<div key={fileEntry.apiPath} className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openChangedFile(fileEntry)}
|
||||
id={`turn-change-opener-${checkpoint.target.targetUserMessageId}-${encodeURIComponent(fileEntry.apiPath)}`}
|
||||
data-source-turn-key={checkpoint.target.targetUserMessageId}
|
||||
onClick={(event) => openChangedFile(event, fileEntry)}
|
||||
aria-label={t('chat.turnChangesOpenInWorkspaceAria', { path: fileEntry.displayPath })}
|
||||
title={fileEntry.displayPath}
|
||||
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
|
||||
@ -175,9 +182,7 @@ export function CurrentTurnChangeCard({
|
||||
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
|
||||
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
|
||||
</span>
|
||||
{!previewable && (
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
|
||||
)}
|
||||
<ChevronRight size={17} strokeWidth={1.9} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
</button>
|
||||
{previewable && (
|
||||
<button
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
buildVirtualItemOffsets,
|
||||
getActiveConversationNavigationItemId,
|
||||
getConversationNavigationTargetScrollTop,
|
||||
isRenderItemFullyVisibleInChatScroller,
|
||||
shouldVirtualizeRenderItems,
|
||||
} from './MessageList'
|
||||
import type { ConversationNavigationItem } from './ConversationNavigator'
|
||||
@ -5079,6 +5080,180 @@ describe('MessageList nested tool calls', () => {
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText(/This model does not support images/)).toBeNull()
|
||||
})
|
||||
|
||||
it('restores opener focus without scrolling when its render item remains fully visible', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{ id: 'assistant-origin', type: 'assistant_text', content: 'review result', timestamp: 1 }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
const { container } = render(<MessageList />)
|
||||
const opener = screen.getByRole('button', { name: 'Copy reply' })
|
||||
opener.id = 'origin-opener'
|
||||
const renderItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="assistant-origin"]')!
|
||||
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')!
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(renderItem, 'scrollIntoView', { configurable: true, value: scrollIntoView })
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
|
||||
vi.spyOn(renderItem, 'getBoundingClientRect').mockReturnValue({ top: 120, bottom: 480, left: 60, right: 620 } as DOMRect)
|
||||
|
||||
await act(async () => {
|
||||
useWorkspacePanelStore.getState().openPanel(ACTIVE_TAB)
|
||||
useWorkspacePanelStore.setState({
|
||||
originBySession: {
|
||||
[ACTIVE_TAB]: { sourceTurnKey: 'assistant-origin', sourceElementId: 'origin-opener' },
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
useWorkspacePanelStore.getState().closePanel(ACTIVE_TAB)
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(opener))
|
||||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scrolls a render item clipped by the chat container before restoring opener focus', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{ id: 'assistant-clipped', type: 'assistant_text', content: 'review result', timestamp: 1 }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
const { container } = render(<MessageList />)
|
||||
const opener = screen.getByRole('button', { name: 'Copy reply' })
|
||||
opener.id = 'clipped-origin-opener'
|
||||
const renderItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="assistant-clipped"]')!
|
||||
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')!
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(renderItem, 'scrollIntoView', { configurable: true, value: scrollIntoView })
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
|
||||
vi.spyOn(renderItem, 'getBoundingClientRect').mockReturnValue({ top: 80, bottom: 460, left: 60, right: 620 } as DOMRect)
|
||||
|
||||
await act(async () => {
|
||||
useWorkspacePanelStore.getState().openPanel(ACTIVE_TAB)
|
||||
useWorkspacePanelStore.setState({
|
||||
originBySession: {
|
||||
[ACTIVE_TAB]: { sourceTurnKey: 'assistant-clipped', sourceElementId: 'clipped-origin-opener' },
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
useWorkspacePanelStore.getState().closePanel(ACTIVE_TAB)
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(opener))
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
})
|
||||
|
||||
it('uses the semantic render key to remount a virtualized origin before focusing it', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: Array.from({ length: 220 }, (_, index) => ({
|
||||
id: `virtual-origin-${index}`,
|
||||
type: 'assistant_text' as const,
|
||||
content: `virtual transcript ${index}`,
|
||||
timestamp: index,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const { container } = render(<MessageList />)
|
||||
expect(container.querySelector('[data-chat-render-item-key="virtual-origin-0"]')).toBeNull()
|
||||
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
useWorkspacePanelStore.getState().openPanel(ACTIVE_TAB)
|
||||
useWorkspacePanelStore.setState({
|
||||
originBySession: {
|
||||
[ACTIVE_TAB]: { sourceTurnKey: 'virtual-origin-0', sourceElementId: 'virtual-origin-opener' },
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
useWorkspacePanelStore.getState().closePanel(ACTIVE_TAB)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
frames.shift()?.(0)
|
||||
await Promise.resolve()
|
||||
})
|
||||
const restoredItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="virtual-origin-0"]')
|
||||
expect(restoredItem).not.toBeNull()
|
||||
const opener = restoredItem!.querySelector<HTMLButtonElement>('[aria-label="Copy reply"]')!
|
||||
opener.id = 'virtual-origin-opener'
|
||||
|
||||
await act(async () => {
|
||||
frames.shift()?.(16)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(document.activeElement).toBe(opener)
|
||||
expect(useWorkspacePanelStore.getState().getOrigin(ACTIVE_TAB)).toBeNull()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('consumes a closed-panel origin when the source conversation mounts after returning from a workbench tab', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{ id: 'assistant-return', type: 'assistant_text', content: 'returned conversation', timestamp: 1 }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
useWorkspacePanelStore.setState({
|
||||
panelBySession: {
|
||||
[ACTIVE_TAB]: { isOpen: false, activeView: 'changed' },
|
||||
},
|
||||
originBySession: {
|
||||
[ACTIVE_TAB]: { sourceTurnKey: 'assistant-return', sourceElementId: 'return-origin-opener' },
|
||||
},
|
||||
})
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
}))
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const opener = screen.getByRole('button', { name: 'Copy reply' })
|
||||
opener.id = 'return-origin-opener'
|
||||
const renderItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="assistant-return"]')!
|
||||
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')!
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(renderItem, 'scrollIntoView', { configurable: true, value: scrollIntoView })
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
|
||||
vi.spyOn(renderItem, 'getBoundingClientRect').mockReturnValue({ top: 60, bottom: 460, left: 60, right: 620 } as DOMRect)
|
||||
|
||||
for (let attempt = 0; attempt < 20 && document.activeElement !== opener; attempt += 1) {
|
||||
const frame = frames.shift()
|
||||
if (!frame) break
|
||||
await act(async () => {
|
||||
frame(attempt * 16)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(document.activeElement).toBe(opener)
|
||||
expect(useWorkspacePanelStore.getState().getOrigin(ACTIVE_TAB)).toBeNull()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldVirtualizeRenderItems', () => {
|
||||
@ -5153,3 +5328,27 @@ describe('conversation navigation layout', () => {
|
||||
expect(getConversationNavigationTargetScrollTop(items[2]!, offsets, 400, 650)).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace panel origin visibility', () => {
|
||||
it('does not request scrolling when the render item is fully visible in the chat scroller', () => {
|
||||
const scroller = document.createElement('div')
|
||||
scroller.className = 'chat-scroll-area'
|
||||
const item = document.createElement('div')
|
||||
scroller.append(item)
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
|
||||
vi.spyOn(item, 'getBoundingClientRect').mockReturnValue({ top: 120, bottom: 480, left: 60, right: 620 } as DOMRect)
|
||||
|
||||
expect(isRenderItemFullyVisibleInChatScroller(item)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects an item clipped by its chat scroller even while inside the window viewport', () => {
|
||||
const scroller = document.createElement('div')
|
||||
scroller.className = 'chat-scroll-area'
|
||||
const item = document.createElement('div')
|
||||
scroller.append(item)
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
|
||||
vi.spyOn(item, 'getBoundingClientRect').mockReturnValue({ top: 80, bottom: 460, left: 60, right: 620 } as DOMRect)
|
||||
|
||||
expect(isRenderItemFullyVisibleInChatScroller(item)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@ import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkspacePanelStore, type WorkspacePanelOrigin } from '../../stores/workspacePanelStore'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
@ -972,6 +973,18 @@ const CHAT_RENDER_ITEM_CLASS = [
|
||||
'chat-render-item',
|
||||
].join(' ')
|
||||
|
||||
export function isRenderItemFullyVisibleInChatScroller(renderItem: HTMLElement) {
|
||||
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')
|
||||
if (!scroller) return false
|
||||
|
||||
const itemRect = renderItem.getBoundingClientRect()
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
return itemRect.top >= scrollerRect.top &&
|
||||
itemRect.bottom <= scrollerRect.bottom &&
|
||||
itemRect.left >= scrollerRect.left &&
|
||||
itemRect.right <= scrollerRect.right
|
||||
}
|
||||
|
||||
type SessionScrollSnapshot = {
|
||||
scrollTop: number
|
||||
wasAtBottom: boolean
|
||||
@ -1415,6 +1428,12 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({
|
||||
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const resolvedSessionId = sessionId ?? activeTabId
|
||||
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
|
||||
resolvedSessionId ? state.isPanelOpen(resolvedSessionId) : false,
|
||||
)
|
||||
const workspacePanelOrigin = useWorkspacePanelStore((state) =>
|
||||
resolvedSessionId ? state.originBySession[resolvedSessionId] ?? null : null,
|
||||
)
|
||||
const sessionState = useChatStore((s) =>
|
||||
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
|
||||
)
|
||||
@ -1458,6 +1477,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const pendingMeasuredHeightsRef = useRef(false)
|
||||
const measureFlushFrameRef = useRef<number | null>(null)
|
||||
const navigationHighlightTimerRef = useRef<number | null>(null)
|
||||
const workspaceOriginRestoreFrameRef = useRef<number | null>(null)
|
||||
const workspaceOriginSessionRef = useRef(resolvedSessionId)
|
||||
const lastAutoScrollAtRef = useRef(0)
|
||||
const lastContentResizeFollowHeightRef = useRef<number | null>(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
@ -1500,6 +1521,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
if (navigationHighlightTimerRef.current !== null) {
|
||||
window.clearTimeout(navigationHighlightTimerRef.current)
|
||||
}
|
||||
if (workspaceOriginRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
|
||||
@ -2180,6 +2204,81 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
virtualViewport.viewportHeight,
|
||||
])
|
||||
|
||||
const restoreWorkspacePanelOrigin = useCallback((origin: WorkspacePanelOrigin, attempt = 0) => {
|
||||
const container = scrollContainerRef.current
|
||||
const content = scrollContentRef.current
|
||||
if (!container || !content || !resolvedSessionId) return
|
||||
|
||||
const renderItem = [...content.querySelectorAll<HTMLElement>('[data-chat-render-item-key]')]
|
||||
.find((node) => node.dataset.chatRenderItemKey === origin.sourceTurnKey)
|
||||
const opener = renderItem
|
||||
? [...renderItem.querySelectorAll<HTMLElement>('[id]')]
|
||||
.find((node) => node.id === origin.sourceElementId)
|
||||
: null
|
||||
|
||||
if (renderItem && opener) {
|
||||
if (!isRenderItemFullyVisibleInChatScroller(renderItem)) {
|
||||
renderItem.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
opener.focus({ preventScroll: true })
|
||||
useWorkspacePanelStore.getState().clearOrigin(resolvedSessionId)
|
||||
workspaceOriginRestoreFrameRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const renderIndex = renderItemKeys.indexOf(origin.sourceTurnKey)
|
||||
if (!renderItem && renderIndex >= 0) {
|
||||
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
||||
const targetScrollTop = clampNumber(
|
||||
(virtualTranscriptWindow.offsets[renderIndex] ?? 0) - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO,
|
||||
0,
|
||||
Math.max(0, virtualTranscriptWindow.totalHeight - viewportHeight),
|
||||
)
|
||||
shouldAutoScrollRef.current = false
|
||||
setScrollTopWithoutLayoutRead(container, targetScrollTop)
|
||||
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
|
||||
}
|
||||
|
||||
if (attempt >= 7 || renderIndex < 0) {
|
||||
useWorkspacePanelStore.getState().clearOrigin(resolvedSessionId)
|
||||
workspaceOriginRestoreFrameRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
workspaceOriginRestoreFrameRef.current = requestAnimationFrame(() => {
|
||||
restoreWorkspacePanelOrigin(origin, attempt + 1)
|
||||
})
|
||||
}, [
|
||||
renderItemKeys,
|
||||
resolvedSessionId,
|
||||
virtualTranscriptWindow.offsets,
|
||||
virtualTranscriptWindow.totalHeight,
|
||||
virtualViewport.viewportHeight,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceOriginSessionRef.current !== resolvedSessionId) {
|
||||
workspaceOriginSessionRef.current = resolvedSessionId
|
||||
if (workspaceOriginRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
|
||||
workspaceOriginRestoreFrameRef.current = null
|
||||
}
|
||||
}
|
||||
if (isWorkspacePanelOpen) {
|
||||
if (workspaceOriginRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
|
||||
workspaceOriginRestoreFrameRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!workspacePanelOrigin || workspaceOriginRestoreFrameRef.current !== null) return
|
||||
|
||||
workspaceOriginRestoreFrameRef.current = requestAnimationFrame(() => {
|
||||
workspaceOriginRestoreFrameRef.current = null
|
||||
restoreWorkspacePanelOrigin(workspacePanelOrigin)
|
||||
})
|
||||
}, [isWorkspacePanelOpen, resolvedSessionId, restoreWorkspacePanelOrigin, workspacePanelOrigin])
|
||||
|
||||
const renderTranscriptItem = (item: RenderItem, index: number) => {
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
|
||||
|
||||
@ -119,10 +119,36 @@ describe('WorkbenchPanel', () => {
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: SESSION_ID,
|
||||
sourceSessionId: SESSION_ID,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the conversation opener origin into the expanded workbench tab', () => {
|
||||
useWorkspacePanelStore.setState({
|
||||
originBySession: {
|
||||
[SESSION_ID]: {
|
||||
sourceTurnKey: 'assistant-turn-3',
|
||||
sourceElementId: 'turn-change-opener-3',
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand panel' }))
|
||||
|
||||
expect(useTabStore.getState().tabs[0]).toMatchObject({
|
||||
sourceSessionId: SESSION_ID,
|
||||
sourceTurnKey: 'assistant-turn-3',
|
||||
sourceElementId: 'turn-change-opener-3',
|
||||
})
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().getOrigin(SESSION_ID)).toEqual({
|
||||
sourceTurnKey: 'assistant-turn-3',
|
||||
sourceElementId: 'turn-change-opener-3',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the tab variant without a nested expand action', () => {
|
||||
const handleClose = vi.fn()
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" onClose={handleClose} />)
|
||||
@ -135,4 +161,15 @@ describe('WorkbenchPanel', () => {
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the tab variant to its source conversation', () => {
|
||||
useTabStore.getState().openTab(SESSION_ID, 'Conversation')
|
||||
const workbenchTabId = useTabStore.getState().openWorkbenchTab(SESSION_ID, 'Workbench')
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to conversation' }))
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBe(SESSION_ID)
|
||||
expect(useTabStore.getState().tabs.some((tab) => tab.sessionId === workbenchTabId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { FolderOpen, Globe, Maximize2, X } from 'lucide-react'
|
||||
import { ArrowLeft, FolderOpen, Globe, Maximize2, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
useWorkspacePanelStore,
|
||||
type WorkbenchMode,
|
||||
} from '../../stores/workspacePanelStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { WORKBENCH_TAB_PREFIX, useTabStore } from '../../stores/tabStore'
|
||||
import { WorkspacePanel } from '../workspace/WorkspacePanel'
|
||||
import { BrowserSurface } from '../browser/BrowserSurface'
|
||||
|
||||
@ -45,7 +45,12 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
|
||||
}
|
||||
|
||||
const handleExpand = () => {
|
||||
useTabStore.getState().openWorkbenchTab(sessionId, t('workbench.tabTitle'))
|
||||
const origin = useWorkspacePanelStore.getState().getOrigin(sessionId)
|
||||
useTabStore.getState().openWorkbenchTab(sessionId, t('workbench.tabTitle'), {
|
||||
sourceSessionId: sessionId,
|
||||
...(origin ?? {}),
|
||||
})
|
||||
closePanel(sessionId)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@ -56,9 +61,28 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
|
||||
closePanel(sessionId)
|
||||
}
|
||||
|
||||
const handleReturn = () => {
|
||||
const store = useTabStore.getState()
|
||||
const activeTab = store.tabs.find((tab) => tab.sessionId === store.activeTabId)
|
||||
const tabId = activeTab?.type === 'workbench' && activeTab.workbenchSessionId === sessionId
|
||||
? activeTab.sessionId
|
||||
: `${WORKBENCH_TAB_PREFIX}${sessionId}`
|
||||
store.returnFromWorkbench(tabId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5">
|
||||
{isTabVariant && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReturn}
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[12px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />
|
||||
<span>{t('workbench.backToConversation')}</span>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('workbench.modeSwitch')}
|
||||
|
||||
@ -1,192 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
|
||||
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
|
||||
|
||||
export const workspacePrismTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: 'var(--color-code-fg)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
styles: [
|
||||
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
|
||||
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
|
||||
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
|
||||
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
|
||||
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
|
||||
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
|
||||
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
|
||||
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
|
||||
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
|
||||
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
|
||||
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
|
||||
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
|
||||
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
|
||||
],
|
||||
}
|
||||
|
||||
export function getFileExtension(name: string) {
|
||||
const cleanName = name.split('/').pop() ?? name
|
||||
const lastDot = cleanName.lastIndexOf('.')
|
||||
if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
|
||||
return cleanName.slice(lastDot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizePrismLanguage(language: string) {
|
||||
const lower = language.toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
text: 'text',
|
||||
typescript: 'typescript',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
javascript: 'javascript',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
markdown: 'markdown',
|
||||
md: 'markdown',
|
||||
html: 'markup',
|
||||
xml: 'markup',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
diff: 'diff',
|
||||
}
|
||||
return map[lower] ?? lower
|
||||
}
|
||||
|
||||
export function getLanguageFromPath(path: string) {
|
||||
return normalizePrismLanguage(getFileExtension(path) || 'text')
|
||||
}
|
||||
|
||||
export function InlineHighlightedCode({
|
||||
value,
|
||||
language,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
}) {
|
||||
return (
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
code={value}
|
||||
language={normalizePrismLanguage(language)}
|
||||
>
|
||||
{({ tokens, getTokenProps }) => (
|
||||
<>
|
||||
{(tokens[0] ?? []).map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceDiffSurface({
|
||||
value,
|
||||
path,
|
||||
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
||||
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
}: {
|
||||
value: string
|
||||
path: string
|
||||
className?: string
|
||||
lineLimit?: number
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [showAllLines, setShowAllLines] = useState(false)
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit)
|
||||
const language = getLanguageFromPath(path)
|
||||
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllLines(false)
|
||||
}, [path, value])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative min-w-max py-2">
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
|
||||
>
|
||||
{visibleLines.map((line, index) => {
|
||||
const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
|
||||
const isHunk = line.startsWith('@@')
|
||||
const isAdded = line.startsWith('+') && !line.startsWith('+++')
|
||||
const isRemoved = line.startsWith('-') && !line.startsWith('---')
|
||||
const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
|
||||
const code = isCodeLine ? line.slice(1) : line
|
||||
const prefix = isCodeLine ? line[0] : ' '
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`grid min-w-full w-max grid-cols-[48px_18px_max-content] gap-2 px-3 ${
|
||||
isAdded
|
||||
? 'bg-[var(--color-diff-added-bg)]'
|
||||
: isRemoved
|
||||
? 'bg-[var(--color-diff-removed-bg)]'
|
||||
: isHunk
|
||||
? 'bg-[var(--color-diff-highlight-bg)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`select-none text-center ${
|
||||
isAdded
|
||||
? 'text-[var(--color-diff-added-text)]'
|
||||
: isRemoved
|
||||
? 'text-[var(--color-diff-removed-text)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span
|
||||
className={`whitespace-pre pr-6 ${
|
||||
isFileHeader
|
||||
? 'font-semibold text-[var(--color-text-secondary)]'
|
||||
: isHunk
|
||||
? 'font-semibold text-[var(--color-warning)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isCodeLine && !usePlainLargePreview ? (
|
||||
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
|
||||
) : (
|
||||
code || ' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
{lines.length > lineLimit && (
|
||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
<span>
|
||||
{showAllLines
|
||||
? t('workspace.previewAllLines', { total: lines.length })
|
||||
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllLines((current) => !current)}
|
||||
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export {
|
||||
getFileExtension,
|
||||
getLanguageFromPath,
|
||||
InlineHighlightedCode,
|
||||
normalizePrismLanguage,
|
||||
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface,
|
||||
workspacePrismTheme,
|
||||
} from './WorkspaceDiffSurface'
|
||||
export type {
|
||||
WorkspaceDiffCommentSelection,
|
||||
WorkspaceDiffSurfaceProps,
|
||||
} from './WorkspaceDiffSurface'
|
||||
|
||||
353
desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx
Normal file
353
desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { WorkspaceDiffSurface } from './WorkspaceDiffSurface'
|
||||
import {
|
||||
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface as ExportedWorkspaceDiffSurface,
|
||||
} from './WorkspaceCodeSurface'
|
||||
|
||||
const highlightRenderSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('prism-react-renderer', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('prism-react-renderer')>()
|
||||
return {
|
||||
...actual,
|
||||
Highlight: (props: ComponentProps<typeof actual.Highlight>) => {
|
||||
highlightRenderSpy()
|
||||
return <actual.Highlight {...props} />
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -10,2 +10,3 @@',
|
||||
' const a = 1',
|
||||
'-const b = 2',
|
||||
'+const b = 3',
|
||||
'+const c = 4',
|
||||
'@@ -20 +21 @@',
|
||||
'-old tail',
|
||||
'+new tail',
|
||||
].join('\n')
|
||||
|
||||
function getCodeRow(text: string) {
|
||||
const row = document.querySelector(`[data-row-text="${text}"]`)
|
||||
expect(row).not.toBeNull()
|
||||
return row!
|
||||
}
|
||||
|
||||
describe('WorkspaceDiffSurface', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
highlightRenderSpy.mockClear()
|
||||
})
|
||||
|
||||
it('submits a forward range with its source coordinates and quote', () => {
|
||||
const onAddComment = vi.fn()
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }), { shiftKey: true })
|
||||
expect(screen.getByText('new L11-L12')).toBeInTheDocument()
|
||||
const rangeEndRow = getCodeRow('const c = 4').closest('[data-diff-row-id]')
|
||||
const editorContainer = screen.getByRole('textbox', { name: 'Review comment' }).closest('[data-diff-editor]')
|
||||
expect(rangeEndRow?.nextElementSibling).toBe(editorContainer)
|
||||
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
|
||||
expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
||||
fireEvent.change(editor, { target: { value: 'Use a shared helper' } })
|
||||
fireEvent.keyDown(editor, { key: 'Enter', metaKey: true })
|
||||
|
||||
expect(onAddComment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
side: 'new',
|
||||
lineStart: 11,
|
||||
lineEnd: 12,
|
||||
quote: 'const b = 3\nconst c = 4',
|
||||
hunkId: 'file-0-hunk-0',
|
||||
}), 'Use a shared helper')
|
||||
})
|
||||
|
||||
it('normalizes reverse Shift selection', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }), { shiftKey: true })
|
||||
|
||||
expect(screen.getByText('new L11-L12')).toBeInTheDocument()
|
||||
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
|
||||
expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('does not submit an empty review comment', () => {
|
||||
const onAddComment = vi.fn()
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Enter', ctrlKey: true })
|
||||
|
||||
expect(onAddComment).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on Escape and restores focus to the anchor gutter button', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
|
||||
|
||||
fireEvent.click(anchor)
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
|
||||
expect(anchor).toHaveFocus()
|
||||
})
|
||||
|
||||
it('resets an incompatible Shift range and announces why', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts old line 20' }), { shiftKey: true })
|
||||
|
||||
expect(screen.getByText('Selection reset: choose lines from the same side and hunk.')).toBeInTheDocument()
|
||||
expect(screen.getByText('old L20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses one roving tab stop and supports Arrow, Home, End, and activation keys', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
const buttons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
|
||||
const firstButton = buttons[0]!
|
||||
const secondButton = buttons[1]!
|
||||
|
||||
expect(buttons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
|
||||
act(() => firstButton.focus())
|
||||
fireEvent.keyDown(firstButton, { key: 'ArrowDown' })
|
||||
expect(secondButton).toHaveFocus()
|
||||
expect(secondButton).toHaveAttribute('tabindex', '0')
|
||||
|
||||
fireEvent.keyDown(secondButton, { key: 'End' })
|
||||
expect(buttons.at(-1)).toHaveFocus()
|
||||
fireEvent.keyDown(buttons.at(-1)!, { key: 'Home' })
|
||||
expect(firstButton).toHaveFocus()
|
||||
fireEvent.keyDown(firstButton, { key: ' ' })
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps Shift+Home selection inside the current side and hunk', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
|
||||
const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
|
||||
|
||||
act(() => line11.focus())
|
||||
fireEvent.keyDown(line11, { key: 'Home', shiftKey: true })
|
||||
|
||||
expect(line10).toHaveFocus()
|
||||
expect(screen.getByText('new L10-L11')).toBeInTheDocument()
|
||||
expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true')
|
||||
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('extends the range with Shift+Arrow and returns focus after submit', () => {
|
||||
const onAddComment = vi.fn()
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
|
||||
const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
|
||||
|
||||
act(() => anchor.focus())
|
||||
fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true })
|
||||
expect(screen.getByText('new L11-L12')).toBeInTheDocument()
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
||||
fireEvent.change(editor, { target: { value: 'Keep this focused' } })
|
||||
fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true })
|
||||
expect(onAddComment).toHaveBeenCalledOnce()
|
||||
expect(anchor).toHaveFocus()
|
||||
})
|
||||
|
||||
it('skips incompatible rows when extending with Shift+Arrow', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
|
||||
|
||||
act(() => anchor.focus())
|
||||
fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true })
|
||||
|
||||
expect(screen.getByText('new L10-L11')).toBeInTheDocument()
|
||||
expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true')
|
||||
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('keeps gutter focus for repeatable Shift+Arrow extension and shrinking', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
|
||||
const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
|
||||
const line12 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
|
||||
|
||||
act(() => line10.focus())
|
||||
fireEvent.keyDown(line10, { key: 'ArrowDown', shiftKey: true })
|
||||
expect(line11).toHaveFocus()
|
||||
fireEvent.keyDown(line11, { key: 'ArrowDown', shiftKey: true })
|
||||
expect(line12).toHaveFocus()
|
||||
expect(screen.getByText('new L10-L12')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(line12, { key: 'ArrowUp', shiftKey: true })
|
||||
expect(line11).toHaveFocus()
|
||||
expect(screen.getByText('new L10-L11')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps roving navigation on mounted rows when the preview is truncated', () => {
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" lineLimit={5} />)
|
||||
const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
|
||||
const lastVisibleButton = visibleButtons.at(-1)!
|
||||
|
||||
act(() => lastVisibleButton.focus())
|
||||
fireEvent.keyDown(lastVisibleButton, { key: 'ArrowDown' })
|
||||
|
||||
expect(lastVisibleButton).toHaveFocus()
|
||||
expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
|
||||
expect(screen.getByText('Showing first 5 of 11 loaded lines.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Show all loaded lines' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('invalidates a hidden selection on collapse while preserving its draft and visible roving target', () => {
|
||||
render(<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.change(screen.getByRole('textbox', { name: 'Review comment' }), {
|
||||
target: { value: 'Keep this collapsed draft' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse preview' }))
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Select visible lines again')
|
||||
const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
|
||||
expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
|
||||
expect(visibleButtons[0]).toHaveFocus()
|
||||
|
||||
fireEvent.click(visibleButtons[0]!)
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Keep this collapsed draft')
|
||||
})
|
||||
|
||||
it('uses plain text instead of Prism after expanding a diff beyond the large preview threshold', () => {
|
||||
const additions = Array.from(
|
||||
{ length: WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + 1 },
|
||||
(_, index) => `+const value${index} = ${index}`,
|
||||
)
|
||||
const largeDiff = [
|
||||
'diff --git a/src/large.ts b/src/large.ts',
|
||||
'--- a/src/large.ts',
|
||||
'+++ b/src/large.ts',
|
||||
`@@ -0,0 +1,${additions.length} @@`,
|
||||
...additions,
|
||||
].join('\n')
|
||||
render(<WorkspaceDiffSurface value={largeDiff} path="src/large.ts" lineLimit={1} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
|
||||
expect(document.querySelector('.token')).not.toBeInTheDocument()
|
||||
expect(getCodeRow('const value5000 = 5000')).toHaveTextContent('const value5000 = 5000')
|
||||
})
|
||||
|
||||
it('renders parsed file headers and keeps multiple files visually separated', () => {
|
||||
const multiFileDiff = [
|
||||
diff,
|
||||
'diff --git a/src/b.ts b/src/b.ts',
|
||||
'--- a/src/b.ts',
|
||||
'+++ b/src/b.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-export const before = true',
|
||||
'+export const after = true',
|
||||
].join('\n')
|
||||
|
||||
render(<WorkspaceDiffSurface value={multiFileDiff} path="workspace.diff" />)
|
||||
|
||||
const headers = screen.getAllByTestId('workspace-diff-file-header')
|
||||
expect(headers).toHaveLength(2)
|
||||
expect(headers[0]).toHaveTextContent('diff --git a/src/a.ts b/src/a.ts')
|
||||
expect(headers[1]).toHaveTextContent('diff --git a/src/b.ts b/src/b.ts')
|
||||
})
|
||||
|
||||
it('renders TypeScript Prism tokens through the compatibility export without a circular runtime failure', () => {
|
||||
render(<ExportedWorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
|
||||
const keyword = screen.getAllByText('const').find((element) => element.classList.contains('keyword'))
|
||||
expect(keyword).toHaveClass('token', 'keyword')
|
||||
expect(document.querySelectorAll('[data-row-text="const b = 3"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders the complete review flow in Chinese', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
|
||||
const gutter = screen.getByRole('button', { name: '评论 src/a.ts 的新侧第 11 行' })
|
||||
fireEvent.click(gutter)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '评审评论' })).toHaveFocus()
|
||||
expect(screen.getByText('新 L11')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '提交评审评论' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '评论 src/a.ts 的旧侧第 20 行' }), { shiftKey: true })
|
||||
expect(screen.getByRole('status')).toHaveTextContent('只能选择同一侧、同一变更块中的行')
|
||||
})
|
||||
|
||||
it('does not rerun Prism highlighting for each controlled draft change', () => {
|
||||
const additions = Array.from(
|
||||
{ length: WORKSPACE_PREVIEW_LINE_LIMIT - 4 },
|
||||
(_, index) => `+const value${index + 1} = ${index + 1}`,
|
||||
)
|
||||
const nearLimitDiff = [
|
||||
'diff --git a/src/near-limit.ts b/src/near-limit.ts',
|
||||
'--- a/src/near-limit.ts',
|
||||
'+++ b/src/near-limit.ts',
|
||||
`@@ -0,0 +1,${additions.length} @@`,
|
||||
...additions,
|
||||
].join('\n')
|
||||
render(<WorkspaceDiffSurface value={nearLimitDiff} path="src/near-limit.ts" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/near-limit.ts new line 1' }))
|
||||
const highlightCountBeforeTyping = highlightRenderSpy.mock.calls.length
|
||||
const editor = screen.getByRole('textbox', { name: 'Review comment' })
|
||||
|
||||
fireEvent.change(editor, { target: { value: 'a' } })
|
||||
fireEvent.change(editor, { target: { value: 'ab' } })
|
||||
fireEvent.change(editor, { target: { value: 'abc' } })
|
||||
|
||||
expect(highlightRenderSpy).toHaveBeenCalledTimes(highlightCountBeforeTyping)
|
||||
expect(editor).toHaveValue('abc')
|
||||
})
|
||||
|
||||
it('preserves draft text but invalidates its selection when the diff changes', () => {
|
||||
const { rerender } = render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
|
||||
target: { value: 'Draft survives refresh' },
|
||||
})
|
||||
rerender(<WorkspaceDiffSurface value={`${diff}\n`} path="src/a.ts" />)
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Diff changed. Select lines again to submit this comment.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Draft survives refresh')
|
||||
})
|
||||
|
||||
it('resets the editor and draft when the path changes', () => {
|
||||
const { rerender } = render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
|
||||
target: { value: 'Discard on another file' },
|
||||
})
|
||||
|
||||
rerender(<WorkspaceDiffSurface value={diff} path="src/b.ts" />)
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/b.ts new line 11' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('')
|
||||
})
|
||||
})
|
||||
556
desktop/src/components/workspace/WorkspaceDiffSurface.tsx
Normal file
556
desktop/src/components/workspace/WorkspaceDiffSurface.tsx
Normal file
@ -0,0 +1,556 @@
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { CornerDownLeft, MessageSquare, Plus } from 'lucide-react'
|
||||
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
getCompatibleDiffRange,
|
||||
parseWorkspaceDiff,
|
||||
type WorkspaceDiffRow,
|
||||
type WorkspaceDiffSelection,
|
||||
} from './workspaceDiffModel'
|
||||
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
|
||||
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
|
||||
|
||||
export const workspacePrismTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: 'var(--color-code-fg)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
styles: [
|
||||
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
|
||||
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
|
||||
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
|
||||
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
|
||||
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
|
||||
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
|
||||
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
|
||||
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
|
||||
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
|
||||
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
|
||||
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
|
||||
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
|
||||
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
|
||||
],
|
||||
}
|
||||
|
||||
export function getFileExtension(name: string) {
|
||||
const cleanName = name.split('/').pop() ?? name
|
||||
const lastDot = cleanName.lastIndexOf('.')
|
||||
if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
|
||||
return cleanName.slice(lastDot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizePrismLanguage(language: string) {
|
||||
const lower = language.toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
text: 'text',
|
||||
typescript: 'typescript',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
javascript: 'javascript',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
markdown: 'markdown',
|
||||
md: 'markdown',
|
||||
html: 'markup',
|
||||
xml: 'markup',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
diff: 'diff',
|
||||
}
|
||||
return map[lower] ?? lower
|
||||
}
|
||||
|
||||
export function getLanguageFromPath(path: string) {
|
||||
return normalizePrismLanguage(getFileExtension(path) || 'text')
|
||||
}
|
||||
|
||||
export const InlineHighlightedCode = memo(function InlineHighlightedCode({
|
||||
value,
|
||||
language,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
}) {
|
||||
return (
|
||||
<Highlight theme={workspacePrismTheme} code={value} language={normalizePrismLanguage(language)}>
|
||||
{({ tokens, getTokenProps }) => (
|
||||
<>
|
||||
{(tokens[0] ?? []).map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
})
|
||||
|
||||
export interface WorkspaceDiffCommentSelection {
|
||||
side: 'old' | 'new'
|
||||
lineStart: number
|
||||
lineEnd: number
|
||||
quote: string
|
||||
hunkId: string
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffSurfaceProps {
|
||||
value: string
|
||||
path: string
|
||||
className?: string
|
||||
lineLimit?: number
|
||||
onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
|
||||
}
|
||||
|
||||
interface ReviewState {
|
||||
anchorId: string | null
|
||||
focusId: string | null
|
||||
selection: WorkspaceDiffSelection | null
|
||||
draft: string
|
||||
}
|
||||
|
||||
type ReviewStatus = 'selectionReset' | 'diffChanged' | 'collapsedSelection' | null
|
||||
|
||||
const emptyReviewState: ReviewState = {
|
||||
anchorId: null,
|
||||
focusId: null,
|
||||
selection: null,
|
||||
draft: '',
|
||||
}
|
||||
|
||||
function rowTone(row: WorkspaceDiffRow) {
|
||||
if (row.kind === 'addition') return 'bg-[var(--color-diff-added-bg)]'
|
||||
if (row.kind === 'deletion') return 'bg-[var(--color-diff-removed-bg)]'
|
||||
if (row.kind === 'hunk') return 'bg-[var(--color-diff-highlight-bg)]'
|
||||
return 'hover:bg-[var(--color-surface-hover)]'
|
||||
}
|
||||
|
||||
function prefixTone(row: WorkspaceDiffRow) {
|
||||
if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]'
|
||||
if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]'
|
||||
return 'text-[var(--color-text-tertiary)]'
|
||||
}
|
||||
|
||||
function codeTone(row: WorkspaceDiffRow) {
|
||||
if (row.kind === 'metadata') return 'font-semibold text-[var(--color-text-secondary)]'
|
||||
if (row.kind === 'hunk') return 'font-semibold text-[var(--color-warning)]'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function WorkspaceDiffSurface({
|
||||
value,
|
||||
path,
|
||||
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
||||
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
onAddComment,
|
||||
}: WorkspaceDiffSurfaceProps) {
|
||||
const t = useTranslation()
|
||||
const files = useMemo(() => parseWorkspaceDiff(value), [value])
|
||||
const rows = useMemo(() => files.flatMap((file) => file.rows), [files])
|
||||
const displayItemIds = useMemo(
|
||||
() => files.flatMap((file) => [`${file.id}-header`, ...file.rows.map((row) => row.id)]),
|
||||
[files],
|
||||
)
|
||||
const [review, setReview] = useState<ReviewState>(emptyReviewState)
|
||||
const [status, setStatus] = useState<ReviewStatus>(null)
|
||||
const [showAllRows, setShowAllRows] = useState(false)
|
||||
const visibleItemIds = useMemo(
|
||||
() => new Set(showAllRows ? displayItemIds : displayItemIds.slice(0, lineLimit)),
|
||||
[displayItemIds, lineLimit, showAllRows],
|
||||
)
|
||||
const visibleRows = useMemo(() => rows.filter((row) => visibleItemIds.has(row.id)), [rows, visibleItemIds])
|
||||
const selectableRows = useMemo(() => visibleRows.filter((row) => row.selectable), [visibleRows])
|
||||
const usePlainLargePreview = showAllRows && rows.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
const [rovingId, setRovingId] = useState<string | null>(() => rows.find((row) => row.selectable)?.id ?? null)
|
||||
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||
const shouldFocusEditor = useRef(false)
|
||||
const pendingRovingFocus = useRef<string | null>(null)
|
||||
const previousPath = useRef(path)
|
||||
const previousValue = useRef(value)
|
||||
const selectedIds = new Set(review.selection?.rowIds ?? [])
|
||||
const sideLabel = (side: 'old' | 'new') => t(`workspace.diffReview.side.${side}`)
|
||||
|
||||
useEffect(() => {
|
||||
const pathChanged = previousPath.current !== path
|
||||
const valueChanged = previousValue.current !== value
|
||||
previousPath.current = path
|
||||
previousValue.current = value
|
||||
|
||||
if (pathChanged) {
|
||||
setReview(emptyReviewState)
|
||||
setStatus(null)
|
||||
setShowAllRows(false)
|
||||
setRovingId(selectableRows[0]?.id ?? null)
|
||||
return
|
||||
}
|
||||
|
||||
if (valueChanged) {
|
||||
setReview((current) => ({
|
||||
...current,
|
||||
anchorId: null,
|
||||
focusId: null,
|
||||
selection: null,
|
||||
}))
|
||||
setStatus(review.draft ? 'diffChanged' : null)
|
||||
setRovingId(selectableRows[0]?.id ?? null)
|
||||
}
|
||||
}, [path, review.draft, selectableRows, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rovingId || !selectableRows.some((row) => row.id === rovingId)) {
|
||||
setRovingId(selectableRows[0]?.id ?? null)
|
||||
}
|
||||
const pendingId = pendingRovingFocus.current
|
||||
if (pendingId && selectableRows.some((row) => row.id === pendingId)) {
|
||||
pendingRovingFocus.current = null
|
||||
setRovingId(pendingId)
|
||||
buttonRefs.current.get(pendingId)?.focus()
|
||||
}
|
||||
}, [rovingId, selectableRows])
|
||||
|
||||
useEffect(() => {
|
||||
if (review.selection && shouldFocusEditor.current) {
|
||||
shouldFocusEditor.current = false
|
||||
editorRef.current?.focus()
|
||||
}
|
||||
}, [review.selection])
|
||||
|
||||
const focusButton = (id: string | null) => {
|
||||
if (id) buttonRefs.current.get(id)?.focus()
|
||||
}
|
||||
|
||||
const selectSingleRow = (row: WorkspaceDiffRow, resetStatus: ReviewStatus = null, focusEditor = false) => {
|
||||
const selection = getCompatibleDiffRange(rows, row.id, row.id)
|
||||
if (!selection) return
|
||||
shouldFocusEditor.current = focusEditor
|
||||
setReview((current) => ({
|
||||
...current,
|
||||
anchorId: row.id,
|
||||
focusId: row.id,
|
||||
selection,
|
||||
}))
|
||||
setStatus(resetStatus)
|
||||
}
|
||||
|
||||
const extendSelection = (row: WorkspaceDiffRow, focusEditor = false) => {
|
||||
if (!review.anchorId) {
|
||||
selectSingleRow(row, null, focusEditor)
|
||||
return
|
||||
}
|
||||
const selection = getCompatibleDiffRange(rows, review.anchorId, row.id)
|
||||
if (!selection) {
|
||||
selectSingleRow(row, 'selectionReset', focusEditor)
|
||||
return
|
||||
}
|
||||
shouldFocusEditor.current = focusEditor
|
||||
setReview((current) => ({ ...current, focusId: row.id, selection }))
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
const activateRow = (row: WorkspaceDiffRow, extend: boolean, focusEditor: boolean) => {
|
||||
setRovingId(row.id)
|
||||
if (extend) extendSelection(row, focusEditor)
|
||||
else selectSingleRow(row, null, focusEditor)
|
||||
}
|
||||
|
||||
const handleRowClick = (event: MouseEvent<HTMLButtonElement>, row: WorkspaceDiffRow) => {
|
||||
activateRow(row, event.shiftKey, true)
|
||||
}
|
||||
|
||||
const moveRovingFocus = (row: WorkspaceDiffRow, direction: -1 | 1, extend: boolean) => {
|
||||
const currentIndex = selectableRows.findIndex((candidate) => candidate.id === row.id)
|
||||
const anchorRow = review.anchorId
|
||||
? selectableRows.find((candidate) => candidate.id === review.anchorId) ?? row
|
||||
: row
|
||||
let target = selectableRows[currentIndex + direction]
|
||||
if (extend) {
|
||||
let targetIndex = currentIndex + direction
|
||||
while (target && (target.side !== anchorRow.side || target.hunkId !== anchorRow.hunkId)) {
|
||||
targetIndex += direction
|
||||
target = selectableRows[targetIndex]
|
||||
}
|
||||
}
|
||||
if (!target) return
|
||||
setRovingId(target.id)
|
||||
focusButton(target.id)
|
||||
if (extend && !review.anchorId) {
|
||||
const selection = getCompatibleDiffRange(rows, row.id, target.id)
|
||||
if (selection) {
|
||||
setReview((current) => ({
|
||||
...current,
|
||||
anchorId: row.id,
|
||||
focusId: target.id,
|
||||
selection,
|
||||
}))
|
||||
setStatus(null)
|
||||
} else {
|
||||
selectSingleRow(target, 'selectionReset')
|
||||
}
|
||||
} else if (extend) {
|
||||
extendSelection(target)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowKeyDown = (event: KeyboardEvent<HTMLButtonElement>, row: WorkspaceDiffRow) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveRovingFocus(row, event.key === 'ArrowDown' ? 1 : -1, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
const navigationRows = event.shiftKey
|
||||
? selectableRows.filter((candidate) => (
|
||||
candidate.side === row.side && candidate.hunkId === row.hunkId
|
||||
))
|
||||
: selectableRows
|
||||
const target = event.key === 'Home' ? navigationRows[0] : navigationRows.at(-1)
|
||||
if (target) {
|
||||
setRovingId(target.id)
|
||||
focusButton(target.id)
|
||||
if (event.shiftKey && !review.anchorId) {
|
||||
const selection = getCompatibleDiffRange(rows, row.id, target.id)
|
||||
if (selection) {
|
||||
setReview((current) => ({
|
||||
...current,
|
||||
anchorId: row.id,
|
||||
focusId: target.id,
|
||||
selection,
|
||||
}))
|
||||
setStatus(null)
|
||||
}
|
||||
} else if (event.shiftKey) {
|
||||
extendSelection(target)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
activateRow(row, event.shiftKey, true)
|
||||
}
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
const restoreId = review.anchorId
|
||||
setReview((current) => ({ ...current, anchorId: null, focusId: null, selection: null }))
|
||||
setStatus(null)
|
||||
focusButton(restoreId)
|
||||
}
|
||||
|
||||
const submitComment = () => {
|
||||
const note = review.draft.trim()
|
||||
if (!note || !review.selection) return
|
||||
const { side, lineStart, lineEnd, quote, hunkId } = review.selection
|
||||
onAddComment?.({ side, lineStart, lineEnd, quote, hunkId }, note)
|
||||
const restoreId = review.anchorId
|
||||
setReview(emptyReviewState)
|
||||
setStatus(null)
|
||||
focusButton(restoreId)
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
submitComment()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRows = () => {
|
||||
if (!showAllRows) {
|
||||
setShowAllRows(true)
|
||||
return
|
||||
}
|
||||
|
||||
const collapsedItemIds = new Set(displayItemIds.slice(0, lineLimit))
|
||||
const collapsedSelectableRows = rows.filter((row) => row.selectable && collapsedItemIds.has(row.id))
|
||||
const nextRovingId = collapsedSelectableRows[0]?.id ?? null
|
||||
const selectionWillBeHidden = review.selection?.rowIds.some((id) => !collapsedItemIds.has(id)) ?? false
|
||||
|
||||
if (selectionWillBeHidden) {
|
||||
setReview((current) => ({
|
||||
...current,
|
||||
anchorId: null,
|
||||
focusId: null,
|
||||
selection: null,
|
||||
}))
|
||||
setStatus('collapsedSelection')
|
||||
}
|
||||
setRovingId(nextRovingId)
|
||||
pendingRovingFocus.current = nextRovingId
|
||||
setShowAllRows(false)
|
||||
}
|
||||
|
||||
const renderEditor = () => review.selection && (
|
||||
<div
|
||||
data-diff-editor=""
|
||||
className="min-w-[420px] border-y border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2"
|
||||
>
|
||||
{status && (
|
||||
<div role="status" aria-live="polite" className="mb-1.5 text-[11px] text-[var(--color-warning)]">
|
||||
{t(`workspace.diffReview.${status}`)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 pl-[84px]">
|
||||
<MessageSquare aria-hidden="true" className="mt-1.5 shrink-0 text-[var(--color-text-tertiary)]" size={14} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
|
||||
</div>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
aria-label={t('workspace.diffReview.editorLabel')}
|
||||
value={review.draft}
|
||||
onChange={(event) => setReview((current) => ({ ...current, draft: event.target.value }))}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
rows={2}
|
||||
className="block min-h-14 w-full resize-y rounded-[6px] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-2 py-1.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.diffReview.submitAria')}
|
||||
disabled={!review.draft.trim()}
|
||||
onClick={submitComment}
|
||||
className="mt-6 inline-flex h-7 min-w-7 items-center justify-center gap-1 rounded-[5px] px-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<CornerDownLeft aria-hidden="true" size={14} />
|
||||
<span>{t('workspace.diffReview.submit')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative min-w-max py-2">
|
||||
<div
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
|
||||
>
|
||||
{files.map((file) => {
|
||||
const headerVisible = visibleItemIds.has(`${file.id}-header`)
|
||||
const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id))
|
||||
if (!headerVisible && fileRows.length === 0) return null
|
||||
const oldPath = file.oldPath ? `a/${file.oldPath}` : '/dev/null'
|
||||
const newPath = file.newPath ? `b/${file.newPath}` : '/dev/null'
|
||||
const language = getLanguageFromPath(file.newPath ?? file.oldPath ?? path)
|
||||
return (
|
||||
<div key={file.id}>
|
||||
{headerVisible && (
|
||||
<div
|
||||
data-testid="workspace-diff-file-header"
|
||||
className="min-h-7 border-y border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1 font-semibold text-[var(--color-text-secondary)]"
|
||||
>
|
||||
diff --git {oldPath} {newPath}
|
||||
</div>
|
||||
)}
|
||||
{fileRows.map((row) => {
|
||||
const line = row.side === 'old' ? row.oldLine : row.newLine
|
||||
const selected = selectedIds.has(row.id)
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<div
|
||||
data-diff-row-id={row.id}
|
||||
className={`group grid min-h-7 min-w-full w-max grid-cols-[42px_42px_28px_18px_minmax(max-content,1fr)] items-stretch px-3 ${rowTone(row)} ${
|
||||
selected ? 'outline outline-1 -outline-offset-1 outline-[var(--color-border-focus)]' : ''
|
||||
}`}
|
||||
>
|
||||
<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-7 w-7 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}
|
||||
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-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--color-border-focus)] ${
|
||||
selected ? 'text-[var(--color-accent)]' : 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{selected ? <MessageSquare aria-hidden="true" size={14} /> : <Plus aria-hidden="true" size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</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)}`}
|
||||
>
|
||||
{row.selectable && row.text && !usePlainLargePreview
|
||||
? <InlineHighlightedCode value={row.text} language={language} />
|
||||
: row.text || ' '}
|
||||
</span>
|
||||
</div>
|
||||
{review.selection?.endId === row.id ? renderEditor() : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{status && !review.selection && (
|
||||
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2">
|
||||
<div role="status" aria-live="polite" className="text-[11px] text-[var(--color-warning)]">
|
||||
{t(`workspace.diffReview.${status}`)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayItemIds.length > lineLimit && (
|
||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>
|
||||
{showAllRows
|
||||
? t('workspace.previewAllLines', { total: displayItemIds.length })
|
||||
: t('workspace.previewLineLimit', { count: visibleItemIds.size, total: displayItemIds.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRows}
|
||||
className="ml-auto h-7 rounded-[5px] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{showAllRows ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -417,6 +417,57 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['diff:src/app.ts', 'modified', 'Modified'],
|
||||
['file:src/app.ts', 'added', 'Added'],
|
||||
] as const)('marks the changed row active for %s and localizes its %s status', async (activeTabId, status, statusLabel) => {
|
||||
const sessionId = `session-active-${status}`
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: { isOpen: true, activeView: 'changed', hasUserSelectedView: true },
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
[sessionId]: {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [{ path: 'src/app.ts', status, additions: 2, deletions: 1 }],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: [{
|
||||
id: activeTabId,
|
||||
path: 'src/app.ts',
|
||||
kind: activeTabId.startsWith('diff:') ? 'diff' : 'file',
|
||||
title: 'app.ts',
|
||||
state: 'ok',
|
||||
...(activeTabId.startsWith('diff:')
|
||||
? { diff: '@@ -1 +1 @@\n-old\n+new' }
|
||||
: { content: 'const app = true', language: 'typescript', size: 16 }),
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
[sessionId]: activeTabId,
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel(sessionId)
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
|
||||
const row = view.getByText('src/app.ts').closest('button')
|
||||
if (!row) throw new Error('Changed file row was not rendered')
|
||||
expect(row.getAttribute('aria-current')).toBe('true')
|
||||
expect(row.className).toContain('bg-[var(--color-surface-selected)]')
|
||||
expect(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
|
||||
})
|
||||
|
||||
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
|
||||
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
@ -1024,11 +1075,12 @@ describe('WorkspacePanel', () => {
|
||||
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
|
||||
|
||||
expect(highlightedCode).toContain('+line 1')
|
||||
expect(highlightedCode).toContain('+line 2000')
|
||||
expect(highlightedCode).not.toContain('+line 2001')
|
||||
expect(highlightedCode).toContain('+line 1999')
|
||||
expect(highlightedCode).not.toContain('+line 2000')
|
||||
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2001')
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
|
||||
})
|
||||
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
||||
@ -1077,11 +1129,11 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
const view = await renderPanel('session-wide-diff')
|
||||
const diffSurface = view.getByTestId('workspace-code')
|
||||
const firstRow = diffSurface.querySelector('div')
|
||||
const firstRow = diffSurface.querySelector('[data-diff-row-id]')
|
||||
|
||||
expect(firstRow?.className).toContain('w-max')
|
||||
expect(firstRow?.className).toContain('min-w-full')
|
||||
expect(firstRow?.className).toContain('grid-cols-[48px_18px_max-content]')
|
||||
expect(firstRow?.className).toContain('grid-cols-[42px_42px_28px_18px_minmax(max-content,1fr)]')
|
||||
expect(diffSurface.textContent).toContain(longDiffLine)
|
||||
})
|
||||
|
||||
@ -1696,6 +1748,87 @@ describe('WorkspacePanel', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('adds a side-aware diff comment, keeps the diff open, and focuses the composer', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-diff-comment': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-diff-comment': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-diff-comment': [{
|
||||
id: 'diff:src/a.ts',
|
||||
path: 'src/a.ts',
|
||||
kind: 'diff',
|
||||
title: 'a.ts',
|
||||
diff: '@@ -10,2 +11,2 @@\n-const result = makeResult()\n-return result\n+const result = buildResult()\n+return result',
|
||||
state: 'ok',
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-diff-comment': 'diff:src/a.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const otherComposerShell = document.createElement('div')
|
||||
otherComposerShell.dataset.testid = 'chat-input-shell'
|
||||
otherComposerShell.dataset.sessionId = 'another-session'
|
||||
otherComposerShell.append(document.createElement('textarea'))
|
||||
document.body.append(otherComposerShell)
|
||||
|
||||
const composerShell = document.createElement('div')
|
||||
composerShell.dataset.testid = 'chat-input-shell'
|
||||
composerShell.dataset.sessionId = 'session-diff-comment'
|
||||
const composer = document.createElement('textarea')
|
||||
composerShell.append(composer)
|
||||
document.body.append(composerShell)
|
||||
|
||||
const view = await renderPanel('session-diff-comment')
|
||||
|
||||
await clickElement(view.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
|
||||
const editor = view.getByRole('textbox', { name: 'Review comment' })
|
||||
await act(() => {
|
||||
fireEvent.change(editor, { target: { value: 'Use a shared helper' } })
|
||||
})
|
||||
await clickElement(view.getByRole('button', { name: 'Submit review comment' }))
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(composer))
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-diff-comment']).toMatchObject([
|
||||
{
|
||||
kind: 'code-comment',
|
||||
path: 'src/a.ts',
|
||||
absolutePath: '/repo/src/a.ts',
|
||||
name: 'a.ts',
|
||||
lineStart: 11,
|
||||
lineEnd: 11,
|
||||
diffSide: 'new',
|
||||
hunkId: expect.any(String),
|
||||
note: 'Use a shared helper',
|
||||
quote: 'const result = buildResult()',
|
||||
},
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-diff-comment']).toBe('diff:src/a.ts')
|
||||
expect(view.getByTestId('workspace-code')).toBeTruthy()
|
||||
composerShell.remove()
|
||||
otherComposerShell.remove()
|
||||
})
|
||||
|
||||
it('adds selected code from a preview to the chat context without requiring a note', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
@ -2068,4 +2201,137 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getByText('status failed')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a loaded diff visible and marks the preview busy while it refreshes', async () => {
|
||||
const refresh = deferred<{ state: 'ok'; path: string; diff: string }>()
|
||||
getMocks().getWorkspaceDiffMock.mockReturnValue(refresh.promise)
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-preview-refresh': { isOpen: true, activeView: 'changed' },
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-preview-refresh': [{
|
||||
id: 'diff:src/a.ts',
|
||||
path: 'src/a.ts',
|
||||
kind: 'diff',
|
||||
title: 'a.ts',
|
||||
state: 'ok',
|
||||
diff: '@@ -1 +1 @@\n-old\n+cached',
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-preview-refresh': 'diff:src/a.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-preview-refresh')
|
||||
expect(view.getByText('cached')).toBeTruthy()
|
||||
|
||||
await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
|
||||
|
||||
expect(view.getByText('cached')).toBeTruthy()
|
||||
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
|
||||
|
||||
refresh.resolve({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@\n-old\n+latest',
|
||||
})
|
||||
await flushReactWork()
|
||||
|
||||
expect(view.getByText('latest')).toBeTruthy()
|
||||
expect(view.queryByText('cached')).toBeNull()
|
||||
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('localizes an initial missing preview without describing it as a refresh failure', async () => {
|
||||
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-initial-missing-zh': { isOpen: true, activeView: 'changed' },
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-initial-missing-zh': [{
|
||||
id: 'diff:src/missing.ts',
|
||||
path: 'src/missing.ts',
|
||||
kind: 'diff',
|
||||
title: 'missing.ts',
|
||||
state: 'missing',
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-initial-missing-zh': 'diff:src/missing.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-initial-missing-zh')
|
||||
|
||||
expect(view.getByText('文件不存在。')).toBeTruthy()
|
||||
expect(view.queryByText(/refresh/i)).toBeNull()
|
||||
expect(view.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
})
|
||||
|
||||
it('localizes stale diff state and completes retry after a non-ok refresh omits an error', async () => {
|
||||
const refresh = deferred<{ state: 'missing'; path: string }>()
|
||||
const retry = deferred<{ state: 'ok'; path: string; diff: string }>()
|
||||
getMocks().getWorkspaceDiffMock
|
||||
.mockReturnValueOnce(refresh.promise)
|
||||
.mockReturnValueOnce(retry.promise)
|
||||
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-preview-refresh-error': { isOpen: true, activeView: 'changed' },
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-preview-refresh-error': [{
|
||||
id: 'diff:src/a.ts',
|
||||
path: 'src/a.ts',
|
||||
kind: 'diff',
|
||||
title: 'a.ts',
|
||||
state: 'ok',
|
||||
diff: '@@ -1 +1 @@\n-old\n+cached',
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-preview-refresh-error': 'diff:src/a.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-preview-refresh-error')
|
||||
await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
|
||||
refresh.resolve({ state: 'missing', path: 'src/a.ts' })
|
||||
await flushReactWork()
|
||||
|
||||
expect(view.getByText('cached')).toBeTruthy()
|
||||
expect(view.getByRole('alert').textContent).toContain('文件不存在。')
|
||||
expect(view.getByRole('alert').textContent).not.toMatch(/refresh/i)
|
||||
await clickElement(view.getByRole('button', { name: '重试' }))
|
||||
|
||||
expect(view.queryByRole('alert')).toBeNull()
|
||||
expect(view.getByText('cached')).toBeTruthy()
|
||||
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
|
||||
|
||||
retry.resolve({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@\n-old\n+recovered',
|
||||
})
|
||||
await flushReactWork()
|
||||
|
||||
expect(view.getByText('recovered')).toBeTruthy()
|
||||
expect(view.queryByText('cached')).toBeNull()
|
||||
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { CircleAlert, Code2, File as FileIcon, FileText, Image as ImageIcon, MessageCircle, RefreshCw, Settings2, type LucideIcon } from 'lucide-react'
|
||||
import { Highlight } from 'prism-react-renderer'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
@ -27,8 +27,10 @@ import {
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface,
|
||||
workspacePrismTheme,
|
||||
type WorkspaceDiffCommentSelection,
|
||||
} from './WorkspaceCodeSurface'
|
||||
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
|
||||
import { getFileIdentity, getWorkspaceStatusLabel, type WorkspaceFileIdentity } from './fileIdentity'
|
||||
|
||||
type WorkspacePanelProps = {
|
||||
sessionId: string
|
||||
@ -102,6 +104,14 @@ const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className:
|
||||
},
|
||||
}
|
||||
|
||||
const FILE_IDENTITY_ICONS: Record<WorkspaceFileIdentity['icon'], LucideIcon> = {
|
||||
code: Code2,
|
||||
config: Settings2,
|
||||
document: FileText,
|
||||
image: ImageIcon,
|
||||
file: FileIcon,
|
||||
}
|
||||
|
||||
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
|
||||
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
|
||||
const EMPTY_EXPANDED_PATHS: string[] = []
|
||||
@ -435,11 +445,12 @@ function WorkspaceFilterInput({
|
||||
}
|
||||
|
||||
function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
|
||||
const t = useTranslation()
|
||||
const meta = FILE_STATUS_META[status]
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[10px] font-semibold ${meta.className}`}
|
||||
aria-label={status}
|
||||
aria-label={getWorkspaceStatusLabel(status, t)}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
@ -765,23 +776,43 @@ function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
|
||||
|
||||
function ChangedFileRow({
|
||||
file,
|
||||
active,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: {
|
||||
file: WorkspaceChangedFile
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
onContextMenu: (event: MouseEvent, path: string) => void
|
||||
}) {
|
||||
const identity = getFileIdentity(file.path)
|
||||
const IdentityIcon = FILE_IDENTITY_ICONS[identity.icon]
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onContextMenu={(event) => onContextMenu(event, file.path)}
|
||||
className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={`mx-2 flex w-[calc(100%-16px)] items-center gap-2.5 rounded-[7px] px-2 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<FileStatusBadge status={file.status} />
|
||||
<span
|
||||
className="inline-flex h-6 min-w-6 shrink-0 items-center justify-center rounded-[6px] bg-[var(--color-surface-container)] 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="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{file.path}</div>
|
||||
<div className="truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{identity.shortLabel}
|
||||
<span className="sr-only"> {identity.languageLabel}</span>
|
||||
</div>
|
||||
{file.oldPath && (
|
||||
<div className="truncate text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{file.oldPath}
|
||||
@ -988,6 +1019,9 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
const activePreviewError = useWorkspacePanelStore((state) =>
|
||||
activePreviewRequestKey ? state.errors.previewByTabId[activePreviewRequestKey] ?? null : null,
|
||||
)
|
||||
const activePreviewRefreshState = useWorkspacePanelStore((state) =>
|
||||
activePreviewRequestKey ? state.errors.previewRefreshStateByTabId[activePreviewRequestKey] ?? null : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const previous = refreshLifecycleRef.current
|
||||
@ -1078,6 +1112,31 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
})
|
||||
}
|
||||
|
||||
const addDiffCommentToChat = (
|
||||
path: string,
|
||||
selection: WorkspaceDiffCommentSelection,
|
||||
note: string,
|
||||
) => {
|
||||
addWorkspaceReference(sessionId, {
|
||||
kind: 'code-comment',
|
||||
path,
|
||||
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
|
||||
name: path.split('/').pop() || path,
|
||||
lineStart: selection.lineStart,
|
||||
lineEnd: selection.lineEnd,
|
||||
diffSide: selection.side,
|
||||
hunkId: selection.hunkId,
|
||||
note,
|
||||
quote: selection.quote,
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
const composer = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="chat-input-shell"]'),
|
||||
).find((element) => element.dataset.sessionId === sessionId)
|
||||
composer?.querySelector<HTMLTextAreaElement>('textarea:not([disabled])')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const addSelectionToChat = (path: string, selection: WorkspaceTextSelection) => {
|
||||
addWorkspaceReference(sessionId, {
|
||||
kind: 'code-selection',
|
||||
@ -1166,6 +1225,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
<ChangedFileRow
|
||||
key={`${file.path}:${file.status}:${file.oldPath ?? ''}`}
|
||||
file={file}
|
||||
active={activePreviewTabId === `file:${file.path}` || activePreviewTabId === `diff:${file.path}`}
|
||||
onClick={() => handleOpenDiff(file.path)}
|
||||
onContextMenu={handleFileContextMenu}
|
||||
/>
|
||||
@ -1239,9 +1299,15 @@ 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)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="workspace-preview-content"
|
||||
aria-busy={activePreviewLoading}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-[12px]">
|
||||
<span className="truncate text-[var(--color-text-tertiary)]">{status?.repoName || 'workspace'}</span>
|
||||
{activePreviewTab.path.split('/').map((segment, index, segments) => (
|
||||
@ -1260,12 +1326,38 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
|
||||
<span>{t('workspace.addToChat')}</span>
|
||||
</button>
|
||||
{activePreviewLoading && state === 'ok' && (
|
||||
<RefreshCw
|
||||
size={13}
|
||||
className="shrink-0 animate-spin text-[var(--color-text-tertiary)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
|
||||
{kindLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activePreviewLoading || state === 'loading' ? (
|
||||
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-[var(--color-error)]/20 bg-[var(--color-error)]/6 px-3 py-2 text-[11px] text-[var(--color-error)]"
|
||||
>
|
||||
<CircleAlert size={15} className="shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">{refreshErrorMessage}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
|
||||
}}
|
||||
className="shrink-0 rounded-[6px] border border-[var(--color-error)]/30 px-2 py-1 font-medium hover:bg-[var(--color-error)]/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-error)]/25"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'loading' || (activePreviewLoading && state !== 'ok') ? (
|
||||
<PanelMessage icon="progress_activity" message={t('workspace.previewState.loading')} />
|
||||
) : state === 'ok' && activePreviewTab.previewType === 'image' ? (
|
||||
<ImagePreview tab={activePreviewTab} />
|
||||
@ -1273,6 +1365,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
<WorkspaceDiffSurface
|
||||
value={activePreviewTab.diff ?? ''}
|
||||
path={activePreviewTab.path}
|
||||
onAddComment={(selection, note) => addDiffCommentToChat(activePreviewTab.path, selection, note)}
|
||||
/>
|
||||
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
|
||||
<MarkdownSurface
|
||||
@ -1293,7 +1386,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
message={getInlineStateMessage(t, state, activePreviewError || activePreviewTab.error || null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
40
desktop/src/components/workspace/fileIdentity.test.ts
Normal file
40
desktop/src/components/workspace/fileIdentity.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { translate, type Locale } from '../../i18n'
|
||||
import { getFileIdentity, getWorkspaceStatusLabel } from './fileIdentity'
|
||||
|
||||
describe('getFileIdentity', () => {
|
||||
it.each([
|
||||
['src/App.tsx', { shortLabel: 'TSX', languageLabel: 'TypeScript React', icon: 'code' }],
|
||||
['main.py', { shortLabel: 'PY', languageLabel: 'Python', icon: 'code' }],
|
||||
['cmd/main.go', { shortLabel: 'GO', languageLabel: 'Go', icon: 'code' }],
|
||||
['src/lib.rs', { shortLabel: 'RS', languageLabel: 'Rust', icon: 'code' }],
|
||||
['config.yaml', { shortLabel: 'YAML', languageLabel: 'YAML', icon: 'config' }],
|
||||
['docker/Dockerfile', { shortLabel: 'DKR', languageLabel: 'Dockerfile', icon: 'config' }],
|
||||
['Makefile', { shortLabel: 'MAKE', languageLabel: 'Makefile', icon: 'config' }],
|
||||
['.gitignore', { shortLabel: 'GIT', languageLabel: 'Git ignore', icon: 'config' }],
|
||||
['notes', { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' }],
|
||||
['assets/hero.png', { shortLabel: 'IMG', languageLabel: 'PNG image', icon: 'image' }],
|
||||
])('recognizes %s', (path, expected) => {
|
||||
expect(getFileIdentity(path)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceStatusLabel', () => {
|
||||
const locales: Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']
|
||||
|
||||
it.each(locales)('returns readable labels for every status in %s', (locale) => {
|
||||
const t = (key: Parameters<typeof translate>[1]) => translate(locale, key)
|
||||
const labels = [
|
||||
'modified',
|
||||
'added',
|
||||
'deleted',
|
||||
'renamed',
|
||||
'untracked',
|
||||
'copied',
|
||||
'type_changed',
|
||||
'unknown',
|
||||
].map((status) => getWorkspaceStatusLabel(status as Parameters<typeof getWorkspaceStatusLabel>[0], t))
|
||||
|
||||
expect(labels.every((label) => label.length > 0 && !label.startsWith('workspace.status.'))).toBe(true)
|
||||
})
|
||||
})
|
||||
95
desktop/src/components/workspace/fileIdentity.ts
Normal file
95
desktop/src/components/workspace/fileIdentity.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { WorkspaceFileStatus } from '../../api/sessions'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
|
||||
export type WorkspaceFileIdentity = {
|
||||
shortLabel: string
|
||||
languageLabel: string
|
||||
icon: 'code' | 'config' | 'document' | 'image' | 'file'
|
||||
}
|
||||
|
||||
type Translate = (key: TranslationKey) => string
|
||||
|
||||
const SPECIAL_BASENAMES: Record<string, WorkspaceFileIdentity> = {
|
||||
dockerfile: { shortLabel: 'DKR', languageLabel: 'Dockerfile', icon: 'config' },
|
||||
makefile: { shortLabel: 'MAKE', languageLabel: 'Makefile', icon: 'config' },
|
||||
'.gitignore': { shortLabel: 'GIT', languageLabel: 'Git ignore', icon: 'config' },
|
||||
'.gitattributes': { shortLabel: 'GIT', languageLabel: 'Git attributes', icon: 'config' },
|
||||
'.editorconfig': { shortLabel: 'CFG', languageLabel: 'EditorConfig', icon: 'config' },
|
||||
license: { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' },
|
||||
readme: { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' },
|
||||
}
|
||||
|
||||
const EXTENSIONS: Record<string, WorkspaceFileIdentity> = {
|
||||
ts: { shortLabel: 'TS', languageLabel: 'TypeScript', icon: 'code' },
|
||||
tsx: { shortLabel: 'TSX', languageLabel: 'TypeScript React', icon: 'code' },
|
||||
js: { shortLabel: 'JS', languageLabel: 'JavaScript', icon: 'code' },
|
||||
jsx: { shortLabel: 'JSX', languageLabel: 'JavaScript React', icon: 'code' },
|
||||
py: { shortLabel: 'PY', languageLabel: 'Python', icon: 'code' },
|
||||
go: { shortLabel: 'GO', languageLabel: 'Go', icon: 'code' },
|
||||
rs: { shortLabel: 'RS', languageLabel: 'Rust', icon: 'code' },
|
||||
java: { shortLabel: 'JAVA', languageLabel: 'Java', icon: 'code' },
|
||||
kt: { shortLabel: 'KT', languageLabel: 'Kotlin', icon: 'code' },
|
||||
swift: { shortLabel: 'SWIFT', languageLabel: 'Swift', icon: 'code' },
|
||||
c: { shortLabel: 'C', languageLabel: 'C', icon: 'code' },
|
||||
cc: { shortLabel: 'C++', languageLabel: 'C++', icon: 'code' },
|
||||
cpp: { shortLabel: 'C++', languageLabel: 'C++', icon: 'code' },
|
||||
h: { shortLabel: 'H', languageLabel: 'C header', icon: 'code' },
|
||||
css: { shortLabel: 'CSS', languageLabel: 'CSS', icon: 'code' },
|
||||
scss: { shortLabel: 'SCSS', languageLabel: 'SCSS', icon: 'code' },
|
||||
html: { shortLabel: 'HTML', languageLabel: 'HTML', icon: 'code' },
|
||||
sh: { shortLabel: 'SH', languageLabel: 'Shell script', icon: 'code' },
|
||||
bash: { shortLabel: 'SH', languageLabel: 'Bash script', icon: 'code' },
|
||||
zsh: { shortLabel: 'ZSH', languageLabel: 'Z shell script', icon: 'code' },
|
||||
yaml: { shortLabel: 'YAML', languageLabel: 'YAML', icon: 'config' },
|
||||
yml: { shortLabel: 'YAML', languageLabel: 'YAML', icon: 'config' },
|
||||
json: { shortLabel: 'JSON', languageLabel: 'JSON', icon: 'config' },
|
||||
toml: { shortLabel: 'TOML', languageLabel: 'TOML', icon: 'config' },
|
||||
ini: { shortLabel: 'INI', languageLabel: 'INI configuration', icon: 'config' },
|
||||
env: { shortLabel: 'ENV', languageLabel: 'Environment configuration', icon: 'config' },
|
||||
md: { shortLabel: 'MD', languageLabel: 'Markdown', icon: 'document' },
|
||||
markdown: { shortLabel: 'MD', languageLabel: 'Markdown', icon: 'document' },
|
||||
txt: { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' },
|
||||
png: { shortLabel: 'IMG', languageLabel: 'PNG image', icon: 'image' },
|
||||
jpg: { shortLabel: 'IMG', languageLabel: 'JPEG image', icon: 'image' },
|
||||
jpeg: { shortLabel: 'IMG', languageLabel: 'JPEG image', icon: 'image' },
|
||||
gif: { shortLabel: 'IMG', languageLabel: 'GIF image', icon: 'image' },
|
||||
webp: { shortLabel: 'IMG', languageLabel: 'WebP image', icon: 'image' },
|
||||
svg: { shortLabel: 'SVG', languageLabel: 'SVG image', icon: 'image' },
|
||||
}
|
||||
|
||||
export function getFileIdentity(path: string): WorkspaceFileIdentity {
|
||||
const basename = path.replace(/\\/g, '/').split('/').pop() || path
|
||||
const normalizedBasename = basename.toLowerCase()
|
||||
const special = SPECIAL_BASENAMES[normalizedBasename]
|
||||
if (special) return special
|
||||
|
||||
const extensionIndex = normalizedBasename.lastIndexOf('.')
|
||||
const extension = extensionIndex >= 0 ? normalizedBasename.slice(extensionIndex + 1) : ''
|
||||
const known = EXTENSIONS[extension]
|
||||
if (known) return known
|
||||
|
||||
if (!extension) {
|
||||
return { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' }
|
||||
}
|
||||
|
||||
return {
|
||||
shortLabel: extension.slice(0, 4).toUpperCase(),
|
||||
languageLabel: `${extension.toUpperCase()} file`,
|
||||
icon: 'file',
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_KEYS: Record<WorkspaceFileStatus, TranslationKey> = {
|
||||
modified: 'workspace.status.modified',
|
||||
added: 'workspace.status.added',
|
||||
deleted: 'workspace.status.deleted',
|
||||
renamed: 'workspace.status.renamed',
|
||||
untracked: 'workspace.status.untracked',
|
||||
copied: 'workspace.status.copied',
|
||||
type_changed: 'workspace.status.typeChanged',
|
||||
unknown: 'workspace.status.unknown',
|
||||
}
|
||||
|
||||
export function getWorkspaceStatusLabel(status: WorkspaceFileStatus, t: Translate) {
|
||||
return t(STATUS_KEYS[status])
|
||||
}
|
||||
150
desktop/src/components/workspace/workspaceDiffModel.test.ts
Normal file
150
desktop/src/components/workspace/workspaceDiffModel.test.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCompatibleDiffRange, parseWorkspaceDiff } from './workspaceDiffModel'
|
||||
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -10,2 +10,3 @@',
|
||||
' const a = 1',
|
||||
'-const b = 2',
|
||||
'+const b = 3',
|
||||
'+const c = 4',
|
||||
'@@ -20 +21 @@',
|
||||
'-old tail',
|
||||
'+new tail',
|
||||
].join('\n')
|
||||
|
||||
describe('parseWorkspaceDiff', () => {
|
||||
it('assigns source coordinates and sides to selectable hunk rows', () => {
|
||||
const file = parseWorkspaceDiff(diff)[0]!
|
||||
|
||||
expect(file.rows.filter((row) => row.selectable).map((row) => ({
|
||||
kind: row.kind,
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
side: row.side,
|
||||
}))).toEqual([
|
||||
{ kind: 'context', oldLine: 10, newLine: 10, side: 'new' },
|
||||
{ kind: 'deletion', oldLine: 11, newLine: null, side: 'old' },
|
||||
{ kind: 'addition', oldLine: null, newLine: 11, side: 'new' },
|
||||
{ kind: 'addition', oldLine: null, newLine: 12, side: 'new' },
|
||||
{ kind: 'deletion', oldLine: 20, newLine: null, side: 'old' },
|
||||
{ kind: 'addition', oldLine: null, newLine: 21, side: 'new' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps malformed headers, binary markers, and no-newline markers as metadata', () => {
|
||||
const file = parseWorkspaceDiff([
|
||||
'diff --git a/image.png b/image.png',
|
||||
'--- a/image.png',
|
||||
'+++ b/image.png',
|
||||
'@@ malformed @@',
|
||||
'Binary files a/image.png and b/image.png differ',
|
||||
'\\ No newline at end of file',
|
||||
].join('\n'))[0]!
|
||||
|
||||
expect(file.rows).toHaveLength(5)
|
||||
expect(file.rows.every((row) => row.kind === 'metadata' && !row.selectable)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps parsing a hunk after a no-newline marker', () => {
|
||||
const file = parseWorkspaceDiff([
|
||||
'diff --git a/a.txt b/a.txt',
|
||||
'@@ -1 +1 @@',
|
||||
'-before',
|
||||
'\\ No newline at end of file',
|
||||
'+after',
|
||||
].join('\n'))[0]!
|
||||
|
||||
expect(file.rows.map((row) => row.kind)).toEqual(['hunk', 'deletion', 'metadata', 'addition'])
|
||||
expect(file.rows[2]).toMatchObject({ selectable: false })
|
||||
expect(file.rows[3]).toMatchObject({ newLine: 1, side: 'new', selectable: true })
|
||||
})
|
||||
|
||||
it('treats hunk content beginning with file-header markers as code', () => {
|
||||
const file = parseWorkspaceDiff([
|
||||
'diff --git a/docs/a.md b/docs/a.md',
|
||||
'--- a/docs/a.md',
|
||||
'+++ b/docs/a.md',
|
||||
'@@ -5,2 +5,2 @@',
|
||||
'--- old heading',
|
||||
'+++ new heading',
|
||||
' tail',
|
||||
].join('\n'))[0]!
|
||||
|
||||
expect({ oldPath: file.oldPath, newPath: file.newPath }).toEqual({
|
||||
oldPath: 'docs/a.md',
|
||||
newPath: 'docs/a.md',
|
||||
})
|
||||
expect(file.rows.filter((row) => row.selectable).map((row) => ({
|
||||
kind: row.kind,
|
||||
text: row.text,
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
side: row.side,
|
||||
}))).toEqual([
|
||||
{ kind: 'deletion', text: '-- old heading', oldLine: 5, newLine: null, side: 'old' },
|
||||
{ kind: 'addition', text: '++ new heading', oldLine: null, newLine: 5, side: 'new' },
|
||||
{ kind: 'context', text: 'tail', oldLine: 6, newLine: 6, side: 'new' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompatibleDiffRange', () => {
|
||||
it('normalizes forward and reverse ranges on the same hunk and side', () => {
|
||||
const file = parseWorkspaceDiff(diff)[0]!
|
||||
const newRows = file.rows.filter((row) => row.selectable && row.hunkId === 'file-0-hunk-0' && row.side === 'new')
|
||||
const expected = {
|
||||
hunkId: 'file-0-hunk-0',
|
||||
side: 'new',
|
||||
startId: newRows[0]!.id,
|
||||
endId: newRows[2]!.id,
|
||||
rowIds: newRows.map((row) => row.id),
|
||||
lineStart: 10,
|
||||
lineEnd: 12,
|
||||
quote: 'const a = 1\nconst b = 3\nconst c = 4',
|
||||
}
|
||||
|
||||
expect(getCompatibleDiffRange(file.rows, newRows[0]!.id, newRows[2]!.id)).toEqual(expected)
|
||||
expect(getCompatibleDiffRange(file.rows, newRows[2]!.id, newRows[0]!.id)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('normalizes forward and reverse ranges on the old side', () => {
|
||||
const file = parseWorkspaceDiff([
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -30,3 +30 @@',
|
||||
'-old first',
|
||||
'-old second',
|
||||
'-old third',
|
||||
'+new value',
|
||||
].join('\n'))[0]!
|
||||
const oldRows = file.rows.filter((row) => row.selectable && row.side === 'old')
|
||||
const expected = {
|
||||
hunkId: 'file-0-hunk-0',
|
||||
side: 'old',
|
||||
startId: oldRows[0]!.id,
|
||||
endId: oldRows[2]!.id,
|
||||
rowIds: oldRows.map((row) => row.id),
|
||||
lineStart: 30,
|
||||
lineEnd: 32,
|
||||
quote: 'old first\nold second\nold third',
|
||||
}
|
||||
|
||||
expect(getCompatibleDiffRange(file.rows, oldRows[0]!.id, oldRows[2]!.id)).toEqual(expected)
|
||||
expect(getCompatibleDiffRange(file.rows, oldRows[2]!.id, oldRows[0]!.id)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects ranges across sides or hunks', () => {
|
||||
const file = parseWorkspaceDiff(diff)[0]!
|
||||
const selectable = file.rows.filter((row) => row.selectable)
|
||||
const context = selectable.find((row) => row.kind === 'context')!
|
||||
const deletion = selectable.find((row) => row.kind === 'deletion')!
|
||||
const secondHunkAddition = selectable.find((row) => row.hunkId === 'file-0-hunk-1' && row.kind === 'addition')!
|
||||
|
||||
expect(getCompatibleDiffRange(file.rows, context.id, deletion.id)).toBeNull()
|
||||
expect(getCompatibleDiffRange(file.rows, context.id, secondHunkAddition.id)).toBeNull()
|
||||
})
|
||||
})
|
||||
213
desktop/src/components/workspace/workspaceDiffModel.ts
Normal file
213
desktop/src/components/workspace/workspaceDiffModel.ts
Normal file
@ -0,0 +1,213 @@
|
||||
export type WorkspaceDiffSide = 'old' | 'new'
|
||||
|
||||
export type WorkspaceDiffRowKind = 'metadata' | 'hunk' | 'context' | 'deletion' | 'addition'
|
||||
|
||||
export interface WorkspaceDiffRow {
|
||||
id: string
|
||||
kind: WorkspaceDiffRowKind
|
||||
text: string
|
||||
prefix: string
|
||||
hunkId: string | null
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
side: WorkspaceDiffSide | null
|
||||
selectable: boolean
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffFile {
|
||||
id: string
|
||||
oldPath: string | null
|
||||
newPath: string | null
|
||||
rows: WorkspaceDiffRow[]
|
||||
}
|
||||
|
||||
export interface WorkspaceDiffSelection {
|
||||
hunkId: string
|
||||
side: WorkspaceDiffSide
|
||||
startId: string
|
||||
endId: string
|
||||
rowIds: string[]
|
||||
lineStart: number
|
||||
lineEnd: number
|
||||
quote: string
|
||||
}
|
||||
|
||||
const hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
||||
|
||||
function pathFromHeader(line: string, prefix: '--- ' | '+++ '): string | null {
|
||||
const path = line.slice(prefix.length).split('\t', 1)[0] ?? ''
|
||||
if (path === '/dev/null') return null
|
||||
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
|
||||
return path
|
||||
}
|
||||
|
||||
export function parseWorkspaceDiff(value: string): WorkspaceDiffFile[] {
|
||||
if (!value) return []
|
||||
|
||||
const files: WorkspaceDiffFile[] = []
|
||||
let file: WorkspaceDiffFile | null = null
|
||||
let hunkId: string | null = null
|
||||
let hunkIndex = 0
|
||||
let oldLine = 0
|
||||
let newLine = 0
|
||||
|
||||
const startFile = (oldPath: string | null = null, newPath: string | null = null) => {
|
||||
file = {
|
||||
id: `file-${files.length}`,
|
||||
oldPath,
|
||||
newPath,
|
||||
rows: [],
|
||||
}
|
||||
files.push(file)
|
||||
hunkId = null
|
||||
hunkIndex = 0
|
||||
}
|
||||
|
||||
const addRow = (
|
||||
kind: WorkspaceDiffRowKind,
|
||||
text: string,
|
||||
prefix = '',
|
||||
coordinates: Pick<WorkspaceDiffRow, 'oldLine' | 'newLine' | 'side' | 'selectable'> = {
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
side: null,
|
||||
selectable: false,
|
||||
},
|
||||
) => {
|
||||
if (!file) startFile()
|
||||
const currentFile = file as WorkspaceDiffFile
|
||||
currentFile.rows.push({
|
||||
id: `${currentFile.id}-row-${currentFile.rows.length}`,
|
||||
kind,
|
||||
text,
|
||||
prefix,
|
||||
hunkId,
|
||||
...coordinates,
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of value.split('\n')) {
|
||||
const fileHeader = /^diff --git a\/(.+) b\/(.+)$/.exec(line)
|
||||
if (fileHeader) {
|
||||
startFile(fileHeader[1], fileHeader[2])
|
||||
continue
|
||||
}
|
||||
|
||||
if (!file) startFile()
|
||||
const currentFile = file!
|
||||
|
||||
if (!hunkId && line.startsWith('--- ')) {
|
||||
currentFile.oldPath = pathFromHeader(line, '--- ')
|
||||
addRow('metadata', line)
|
||||
continue
|
||||
}
|
||||
if (!hunkId && line.startsWith('+++ ')) {
|
||||
currentFile.newPath = pathFromHeader(line, '+++ ')
|
||||
addRow('metadata', line)
|
||||
continue
|
||||
}
|
||||
|
||||
const hunkHeader = hunkHeaderPattern.exec(line)
|
||||
if (hunkHeader) {
|
||||
hunkId = `${currentFile.id}-hunk-${hunkIndex}`
|
||||
hunkIndex += 1
|
||||
oldLine = Number(hunkHeader[1])
|
||||
newLine = Number(hunkHeader[3])
|
||||
addRow('hunk', line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line === '\\ No newline at end of file') {
|
||||
addRow('metadata', line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('@@') || line.startsWith('Binary ')) {
|
||||
hunkId = null
|
||||
addRow('metadata', line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (hunkId && line.startsWith('+')) {
|
||||
addRow('addition', line.slice(1), '+', {
|
||||
oldLine: null,
|
||||
newLine,
|
||||
side: 'new',
|
||||
selectable: true,
|
||||
})
|
||||
newLine += 1
|
||||
continue
|
||||
}
|
||||
if (hunkId && line.startsWith('-')) {
|
||||
addRow('deletion', line.slice(1), '-', {
|
||||
oldLine,
|
||||
newLine: null,
|
||||
side: 'old',
|
||||
selectable: true,
|
||||
})
|
||||
oldLine += 1
|
||||
continue
|
||||
}
|
||||
if (hunkId && line.startsWith(' ')) {
|
||||
addRow('context', line.slice(1), ' ', {
|
||||
oldLine,
|
||||
newLine,
|
||||
side: 'new',
|
||||
selectable: true,
|
||||
})
|
||||
oldLine += 1
|
||||
newLine += 1
|
||||
continue
|
||||
}
|
||||
|
||||
addRow('metadata', line)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export function getCompatibleDiffRange(
|
||||
rows: WorkspaceDiffRow[],
|
||||
anchorId: string,
|
||||
targetId: string,
|
||||
): WorkspaceDiffSelection | null {
|
||||
const anchorIndex = rows.findIndex((row) => row.id === anchorId)
|
||||
const targetIndex = rows.findIndex((row) => row.id === targetId)
|
||||
if (anchorIndex < 0 || targetIndex < 0) return null
|
||||
|
||||
const anchor = rows[anchorIndex]
|
||||
const target = rows[targetIndex]
|
||||
if (!anchor || !target) return null
|
||||
if (
|
||||
!anchor.selectable
|
||||
|| !target.selectable
|
||||
|| !anchor.hunkId
|
||||
|| anchor.hunkId !== target.hunkId
|
||||
|| !anchor.side
|
||||
|| anchor.side !== target.side
|
||||
) return null
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex)
|
||||
const endIndex = Math.max(anchorIndex, targetIndex)
|
||||
const selectedRows = rows.slice(startIndex, endIndex + 1).filter((row) => (
|
||||
row.selectable && row.hunkId === anchor.hunkId && row.side === anchor.side
|
||||
))
|
||||
const lineNumbers = selectedRows.map((row) => (
|
||||
anchor.side === 'old' ? row.oldLine : row.newLine
|
||||
)).filter((line): line is number => line !== null)
|
||||
|
||||
if (selectedRows.length === 0 || lineNumbers.length !== selectedRows.length) return null
|
||||
const firstRow = selectedRows[0]!
|
||||
const lastRow = selectedRows[selectedRows.length - 1]!
|
||||
|
||||
return {
|
||||
hunkId: anchor.hunkId,
|
||||
side: anchor.side,
|
||||
startId: firstRow.id,
|
||||
endId: lastRow.id,
|
||||
rowIds: selectedRows.map((row) => row.id),
|
||||
lineStart: lineNumbers[0]!,
|
||||
lineEnd: lineNumbers[lineNumbers.length - 1]!,
|
||||
quote: selectedRows.map((row) => row.text).join('\n'),
|
||||
}
|
||||
}
|
||||
@ -146,6 +146,7 @@ export const en = {
|
||||
'workbench.expand': 'Expand panel',
|
||||
'workbench.close': 'Close',
|
||||
'workbench.tabTitle': 'Workbench',
|
||||
'workbench.backToConversation': 'Back to conversation',
|
||||
'workspace.closeTab': 'Close tab',
|
||||
'workspace.preview': 'Preview',
|
||||
'workspace.previewEmpty': 'Select a file to preview.',
|
||||
@ -157,6 +158,14 @@ export const en = {
|
||||
'workspace.noMatchingFiles': 'No matching files',
|
||||
'workspace.previewKind.diff': 'Diff',
|
||||
'workspace.previewKind.file': 'File',
|
||||
'workspace.status.modified': 'Modified',
|
||||
'workspace.status.added': 'Added',
|
||||
'workspace.status.deleted': 'Deleted',
|
||||
'workspace.status.renamed': 'Renamed',
|
||||
'workspace.status.untracked': 'Untracked',
|
||||
'workspace.status.copied': 'Copied',
|
||||
'workspace.status.typeChanged': 'Type changed',
|
||||
'workspace.status.unknown': 'Unknown status',
|
||||
'workspace.previewState.loading': 'Loading preview...',
|
||||
'workspace.previewState.binary': 'Binary file preview is unavailable.',
|
||||
'workspace.previewState.tooLarge': 'File is too large to preview.',
|
||||
@ -176,6 +185,16 @@ export const en = {
|
||||
'workspace.commentLineTarget': 'Line {line}',
|
||||
'workspace.commentPlaceholder': 'Describe what should change here...',
|
||||
'workspace.addCommentToChat': 'Add comment',
|
||||
'workspace.diffReview.commentLineAria': 'Comment on {path} {side} line {line}',
|
||||
'workspace.diffReview.editorLabel': 'Review comment',
|
||||
'workspace.diffReview.submit': 'Submit',
|
||||
'workspace.diffReview.submitAria': 'Submit review comment',
|
||||
'workspace.diffReview.side.old': 'old',
|
||||
'workspace.diffReview.side.new': 'new',
|
||||
'workspace.diffReview.selectionReset': 'Selection reset: choose lines from the same side and hunk.',
|
||||
'workspace.diffReview.diffChanged': 'Diff changed. Select lines again to submit this comment.',
|
||||
'workspace.diffReview.collapsedSelection': 'Selection is outside the collapsed preview. Select visible lines again to submit this comment.',
|
||||
'attachments.remove': 'Remove {name}',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': 'Connected',
|
||||
@ -1411,6 +1430,7 @@ export const en = {
|
||||
'chat.turnChangesHistoricalCardLabel': 'Turn changed files',
|
||||
'chat.turnChangesLatestSubtitle': 'Current turn checkpoint',
|
||||
'chat.turnChangesHistoricalSubtitle': 'Saved turn checkpoint',
|
||||
'chat.turnChangesCurrentWorkspaceDiff': 'Current workspace diff',
|
||||
'chat.turnChangesLatestUndo': 'Undo current turn',
|
||||
'chat.turnChangesHistoricalUndo': 'Rewind to before this turn',
|
||||
'chat.turnChangesUndoing': 'Undoing...',
|
||||
|
||||
@ -148,6 +148,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workbench.expand': 'パネルを展開',
|
||||
'workbench.close': '閉じる',
|
||||
'workbench.tabTitle': 'ワークベンチ',
|
||||
'workbench.backToConversation': '会話に戻る',
|
||||
'workspace.closeTab': 'タブを閉じる',
|
||||
'workspace.preview': 'プレビュー',
|
||||
'workspace.previewEmpty': 'プレビューするファイルを選択してください。',
|
||||
@ -159,6 +160,14 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workspace.noMatchingFiles': '一致するファイルなし',
|
||||
'workspace.previewKind.diff': '差分',
|
||||
'workspace.previewKind.file': 'ファイル',
|
||||
'workspace.status.modified': '変更済み',
|
||||
'workspace.status.added': '追加済み',
|
||||
'workspace.status.deleted': '削除済み',
|
||||
'workspace.status.renamed': '名前変更済み',
|
||||
'workspace.status.untracked': '未追跡',
|
||||
'workspace.status.copied': 'コピー済み',
|
||||
'workspace.status.typeChanged': '種類変更済み',
|
||||
'workspace.status.unknown': '不明な状態',
|
||||
'workspace.previewState.loading': 'プレビューを読み込み中...',
|
||||
'workspace.previewState.binary': 'バイナリファイルはプレビューできません。',
|
||||
'workspace.previewState.tooLarge': 'ファイルが大きすぎてプレビューできません。',
|
||||
@ -178,6 +187,16 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workspace.commentLineTarget': '{line} 行目',
|
||||
'workspace.commentPlaceholder': 'ここで何を変更すべきか説明してください...',
|
||||
'workspace.addCommentToChat': 'コメントを追加',
|
||||
'workspace.diffReview.commentLineAria': '{path} の{side}側 {line} 行目にコメント',
|
||||
'workspace.diffReview.editorLabel': 'レビューコメント',
|
||||
'workspace.diffReview.submit': '送信',
|
||||
'workspace.diffReview.submitAria': 'レビューコメントを送信',
|
||||
'workspace.diffReview.side.old': '旧',
|
||||
'workspace.diffReview.side.new': '新',
|
||||
'workspace.diffReview.selectionReset': '選択をリセットしました。同じ側の同じハンク内の行を選択してください。',
|
||||
'workspace.diffReview.diffChanged': '差分が更新されました。このコメントを送信するには行を選択し直してください。',
|
||||
'workspace.diffReview.collapsedSelection': '選択した行は折りたたまれたプレビューの範囲外です。表示中の行を選択し直してください。',
|
||||
'attachments.remove': '{name} を削除',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '接続済み',
|
||||
@ -1413,6 +1432,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.turnChangesHistoricalCardLabel': 'ターンで変更されたファイル',
|
||||
'chat.turnChangesLatestSubtitle': '現在のターンのチェックポイント',
|
||||
'chat.turnChangesHistoricalSubtitle': '保存済みターンのチェックポイント',
|
||||
'chat.turnChangesCurrentWorkspaceDiff': '現在のワークスペース差分',
|
||||
'chat.turnChangesLatestUndo': '現在のターンを元に戻す',
|
||||
'chat.turnChangesHistoricalUndo': 'このターンの前まで巻き戻す',
|
||||
'chat.turnChangesUndoing': '元に戻しています...',
|
||||
|
||||
@ -148,6 +148,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workbench.expand': '패널 펼치기',
|
||||
'workbench.close': '닫기',
|
||||
'workbench.tabTitle': '워크벤치',
|
||||
'workbench.backToConversation': '대화로 돌아가기',
|
||||
'workspace.closeTab': '탭 닫기',
|
||||
'workspace.preview': '미리 보기',
|
||||
'workspace.previewEmpty': '미리 볼 파일을 선택하세요.',
|
||||
@ -159,6 +160,14 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workspace.noMatchingFiles': '일치하는 파일 없음',
|
||||
'workspace.previewKind.diff': '변경 내용',
|
||||
'workspace.previewKind.file': '파일',
|
||||
'workspace.status.modified': '수정됨',
|
||||
'workspace.status.added': '추가됨',
|
||||
'workspace.status.deleted': '삭제됨',
|
||||
'workspace.status.renamed': '이름 변경됨',
|
||||
'workspace.status.untracked': '추적되지 않음',
|
||||
'workspace.status.copied': '복사됨',
|
||||
'workspace.status.typeChanged': '유형 변경됨',
|
||||
'workspace.status.unknown': '알 수 없는 상태',
|
||||
'workspace.previewState.loading': '미리 보기 불러오는 중...',
|
||||
'workspace.previewState.binary': '바이너리 파일은 미리 볼 수 없습니다.',
|
||||
'workspace.previewState.tooLarge': '파일이 너무 커서 미리 볼 수 없습니다.',
|
||||
@ -178,6 +187,16 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workspace.commentLineTarget': '{line}번째 줄',
|
||||
'workspace.commentPlaceholder': '여기서 무엇을 변경해야 하는지 설명하세요...',
|
||||
'workspace.addCommentToChat': '댓글 추가',
|
||||
'workspace.diffReview.commentLineAria': '{path}의 {side} 쪽 {line}번 줄에 댓글 달기',
|
||||
'workspace.diffReview.editorLabel': '리뷰 댓글',
|
||||
'workspace.diffReview.submit': '제출',
|
||||
'workspace.diffReview.submitAria': '리뷰 댓글 제출',
|
||||
'workspace.diffReview.side.old': '이전',
|
||||
'workspace.diffReview.side.new': '새',
|
||||
'workspace.diffReview.selectionReset': '선택이 재설정되었습니다. 같은 쪽과 같은 헝크의 줄을 선택하세요.',
|
||||
'workspace.diffReview.diffChanged': 'Diff가 변경되었습니다. 이 댓글을 제출하려면 줄을 다시 선택하세요.',
|
||||
'workspace.diffReview.collapsedSelection': '선택한 줄이 접힌 미리보기 범위 밖에 있습니다. 표시된 줄을 다시 선택하세요.',
|
||||
'attachments.remove': '{name} 제거',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '연결됨',
|
||||
@ -1413,6 +1432,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.turnChangesHistoricalCardLabel': '턴에서 변경된 파일',
|
||||
'chat.turnChangesLatestSubtitle': '현재 턴 체크포인트',
|
||||
'chat.turnChangesHistoricalSubtitle': '저장된 턴 체크포인트',
|
||||
'chat.turnChangesCurrentWorkspaceDiff': '현재 워크스페이스 변경 사항',
|
||||
'chat.turnChangesLatestUndo': '현재 턴 실행 취소',
|
||||
'chat.turnChangesHistoricalUndo': '이 턴 이전으로 되감기',
|
||||
'chat.turnChangesUndoing': '실행 취소 중...',
|
||||
|
||||
@ -148,6 +148,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workbench.expand': '展開面板',
|
||||
'workbench.close': '關閉',
|
||||
'workbench.tabTitle': '工作臺',
|
||||
'workbench.backToConversation': '返回對話',
|
||||
'workspace.closeTab': '關閉標籤',
|
||||
'workspace.preview': '預覽',
|
||||
'workspace.previewEmpty': '選擇一個檔案進行預覽。',
|
||||
@ -159,6 +160,14 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.noMatchingFiles': '沒有匹配的檔案',
|
||||
'workspace.previewKind.diff': 'Diff',
|
||||
'workspace.previewKind.file': '檔案',
|
||||
'workspace.status.modified': '已修改',
|
||||
'workspace.status.added': '已新增',
|
||||
'workspace.status.deleted': '已刪除',
|
||||
'workspace.status.renamed': '已重新命名',
|
||||
'workspace.status.untracked': '未追蹤',
|
||||
'workspace.status.copied': '已複製',
|
||||
'workspace.status.typeChanged': '類型已變更',
|
||||
'workspace.status.unknown': '未知狀態',
|
||||
'workspace.previewState.loading': '正在載入預覽...',
|
||||
'workspace.previewState.binary': '二進位制檔案暫不支援預覽。',
|
||||
'workspace.previewState.tooLarge': '檔案過大,無法預覽。',
|
||||
@ -178,6 +187,16 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.commentLineTarget': '第 {line} 行',
|
||||
'workspace.commentPlaceholder': '描述你希望這裡怎麼改...',
|
||||
'workspace.addCommentToChat': '新增評論',
|
||||
'workspace.diffReview.commentLineAria': '評論 {path} 的{side}側第 {line} 行',
|
||||
'workspace.diffReview.editorLabel': '審查評論',
|
||||
'workspace.diffReview.submit': '提交',
|
||||
'workspace.diffReview.submitAria': '提交審查評論',
|
||||
'workspace.diffReview.side.old': '舊',
|
||||
'workspace.diffReview.side.new': '新',
|
||||
'workspace.diffReview.selectionReset': '選取已重設:只能選擇同一側、同一變更區塊中的行。',
|
||||
'workspace.diffReview.diffChanged': '差異已更新。請重新選擇行以提交此評論。',
|
||||
'workspace.diffReview.collapsedSelection': '所選行不在收合後的預覽中。請重新選擇可見行以提交此評論。',
|
||||
'attachments.remove': '移除 {name}',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '已連線',
|
||||
@ -1413,6 +1432,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.turnChangesHistoricalCardLabel': '輪次已更改檔案',
|
||||
'chat.turnChangesLatestSubtitle': '當前輪次檢查點',
|
||||
'chat.turnChangesHistoricalSubtitle': '歷史輪次檢查點',
|
||||
'chat.turnChangesCurrentWorkspaceDiff': '目前工作區差異',
|
||||
'chat.turnChangesLatestUndo': '撤銷當前輪次',
|
||||
'chat.turnChangesHistoricalUndo': '回滾到這一輪之前',
|
||||
'chat.turnChangesUndoing': '正在撤銷...',
|
||||
|
||||
@ -148,6 +148,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workbench.expand': '展开面板',
|
||||
'workbench.close': '关闭',
|
||||
'workbench.tabTitle': '工作台',
|
||||
'workbench.backToConversation': '返回对话',
|
||||
'workspace.closeTab': '关闭标签',
|
||||
'workspace.preview': '预览',
|
||||
'workspace.previewEmpty': '选择一个文件进行预览。',
|
||||
@ -159,6 +160,14 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.noMatchingFiles': '没有匹配的文件',
|
||||
'workspace.previewKind.diff': 'Diff',
|
||||
'workspace.previewKind.file': '文件',
|
||||
'workspace.status.modified': '已修改',
|
||||
'workspace.status.added': '已新增',
|
||||
'workspace.status.deleted': '已删除',
|
||||
'workspace.status.renamed': '已重命名',
|
||||
'workspace.status.untracked': '未跟踪',
|
||||
'workspace.status.copied': '已复制',
|
||||
'workspace.status.typeChanged': '类型已变更',
|
||||
'workspace.status.unknown': '未知状态',
|
||||
'workspace.previewState.loading': '正在加载预览...',
|
||||
'workspace.previewState.binary': '二进制文件暂不支持预览。',
|
||||
'workspace.previewState.tooLarge': '文件过大,无法预览。',
|
||||
@ -178,6 +187,16 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.commentLineTarget': '第 {line} 行',
|
||||
'workspace.commentPlaceholder': '描述你希望这里怎么改...',
|
||||
'workspace.addCommentToChat': '添加评论',
|
||||
'workspace.diffReview.commentLineAria': '评论 {path} 的{side}侧第 {line} 行',
|
||||
'workspace.diffReview.editorLabel': '评审评论',
|
||||
'workspace.diffReview.submit': '提交',
|
||||
'workspace.diffReview.submitAria': '提交评审评论',
|
||||
'workspace.diffReview.side.old': '旧',
|
||||
'workspace.diffReview.side.new': '新',
|
||||
'workspace.diffReview.selectionReset': '选择已重置:只能选择同一侧、同一变更块中的行。',
|
||||
'workspace.diffReview.diffChanged': '差异已更新。请重新选择行以提交此评论。',
|
||||
'workspace.diffReview.collapsedSelection': '所选行不在收起后的预览中。请重新选择可见行以提交此评论。',
|
||||
'attachments.remove': '移除 {name}',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '已连接',
|
||||
@ -1413,6 +1432,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
|
||||
'chat.turnChangesLatestSubtitle': '当前轮次检查点',
|
||||
'chat.turnChangesHistoricalSubtitle': '历史轮次检查点',
|
||||
'chat.turnChangesCurrentWorkspaceDiff': '当前工作区差异',
|
||||
'chat.turnChangesLatestUndo': '撤销当前轮次',
|
||||
'chat.turnChangesHistoricalUndo': '回滚到这一轮之前',
|
||||
'chat.turnChangesUndoing': '正在撤销...',
|
||||
|
||||
@ -12,6 +12,8 @@ export type ComposerAttachment = {
|
||||
isDirectory?: boolean
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
diffSide?: 'old' | 'new'
|
||||
hunkId?: string
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
@ -76,6 +76,7 @@ describe('tabStore', () => {
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: 'session-1',
|
||||
sourceSessionId: 'session-1',
|
||||
},
|
||||
])
|
||||
expect(useTabStore.getState().activeTabId).toBe('__workbench__session-1')
|
||||
@ -85,6 +86,48 @@ describe('tabStore', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns an ephemeral workbench tab to its source session before closing it', () => {
|
||||
useTabStore.getState().openTab('session-a', 'Session A')
|
||||
useTabStore.getState().openTab('session-b', 'Session B')
|
||||
const tabId = useTabStore.getState().openWorkbenchTab('session-b', 'Workbench', {
|
||||
sourceSessionId: 'session-b',
|
||||
sourceTurnKey: 'assistant:turn-2',
|
||||
sourceElementId: 'turn-change-session-b-main-ts',
|
||||
})
|
||||
|
||||
useTabStore.getState().returnFromWorkbench(tabId)
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBe('session-b')
|
||||
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['session-a', 'session-b'])
|
||||
})
|
||||
|
||||
it('defaults a workbench origin to its source session and keeps it ephemeral', () => {
|
||||
useTabStore.getState().openTab('session-a', 'Session A')
|
||||
const tabId = useTabStore.getState().openWorkbenchTab('session-a', 'Workbench')
|
||||
|
||||
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === tabId)).toMatchObject({
|
||||
sourceSessionId: 'session-a',
|
||||
})
|
||||
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
|
||||
openTabs: [{ sessionId: 'session-a', title: 'Session A', type: 'session' }],
|
||||
activeTabId: 'session-a',
|
||||
}))
|
||||
})
|
||||
|
||||
it('persists the source session as active while its ephemeral workbench is active', () => {
|
||||
useTabStore.getState().openTab('session-a', 'Session A')
|
||||
useTabStore.getState().openTab('session-b', 'Session B')
|
||||
useTabStore.getState().openWorkbenchTab('session-b', 'Workbench')
|
||||
|
||||
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
|
||||
openTabs: [
|
||||
{ sessionId: 'session-a', title: 'Session A', type: 'session' },
|
||||
{ sessionId: 'session-b', title: 'Session B', type: 'session' },
|
||||
],
|
||||
activeTabId: 'session-b',
|
||||
}))
|
||||
})
|
||||
|
||||
it('opens one ephemeral SubAgent tab per source session and tool use', () => {
|
||||
const tabId = useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn')
|
||||
const sameTabId = useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn updated')
|
||||
|
||||
@ -27,9 +27,17 @@ export type Tab = {
|
||||
traceSessionId?: string
|
||||
workbenchSessionId?: string
|
||||
sourceSessionId?: string
|
||||
sourceTurnKey?: string
|
||||
sourceElementId?: string
|
||||
subagentToolUseId?: string
|
||||
}
|
||||
|
||||
export type WorkbenchTabOrigin = {
|
||||
sourceSessionId?: string
|
||||
sourceTurnKey?: string
|
||||
sourceElementId?: string
|
||||
}
|
||||
|
||||
type TabPersistence = {
|
||||
openTabs: Array<{ sessionId: string; title: string; type?: TabType; traceSessionId?: string }>
|
||||
activeTabId: string | null
|
||||
@ -43,7 +51,8 @@ type TabStore = {
|
||||
openTracesTab: (title?: string) => string
|
||||
openTraceTab: (sessionId: string, title?: string) => string
|
||||
openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
|
||||
openWorkbenchTab: (sessionId: string, title?: string) => string
|
||||
openWorkbenchTab: (sessionId: string, title?: string, origin?: WorkbenchTabOrigin) => string
|
||||
returnFromWorkbench: (tabId: string) => void
|
||||
openSubagentTab: (sourceSessionId: string, toolUseId: string, title?: string) => string
|
||||
closeTab: (sessionId: string) => void
|
||||
setActiveTab: (sessionId: string) => void
|
||||
@ -168,7 +177,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
return sessionId
|
||||
},
|
||||
|
||||
openWorkbenchTab: (sessionId, title = 'Workbench') => {
|
||||
openWorkbenchTab: (sessionId, title = 'Workbench', origin) => {
|
||||
const tabId = `${WORKBENCH_TAB_PREFIX}${sessionId}`
|
||||
const { tabs } = get()
|
||||
const existing = tabs.find((tab) => tab.sessionId === tabId)
|
||||
@ -178,6 +187,9 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
type: 'workbench',
|
||||
status: 'idle',
|
||||
workbenchSessionId: sessionId,
|
||||
sourceSessionId: origin?.sourceSessionId ?? sessionId,
|
||||
...(origin?.sourceTurnKey ? { sourceTurnKey: origin.sourceTurnKey } : {}),
|
||||
...(origin?.sourceElementId ? { sourceElementId: origin.sourceElementId } : {}),
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
@ -195,6 +207,16 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
return tabId
|
||||
},
|
||||
|
||||
returnFromWorkbench: (tabId) => {
|
||||
const tab = get().tabs.find((current) => current.sessionId === tabId)
|
||||
if (tab?.type !== 'workbench') return
|
||||
|
||||
if (tab.sourceSessionId && get().tabs.some((current) => current.sessionId === tab.sourceSessionId)) {
|
||||
get().setActiveTab(tab.sourceSessionId)
|
||||
}
|
||||
get().closeTab(tabId)
|
||||
},
|
||||
|
||||
openSubagentTab: (sourceSessionId, toolUseId, title = 'SubAgent') => {
|
||||
const tabId = `${SUBAGENT_TAB_PREFIX}${sourceSessionId}__${toolUseId}`
|
||||
const { tabs } = get()
|
||||
@ -288,6 +310,12 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
saveTabs: () => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench' && tab.type !== 'subagent')
|
||||
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
|
||||
const persistedActiveTabId = activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
|
||||
? activeTabId
|
||||
: activeTab?.type === 'workbench' && activeTab.sourceSessionId && persistableTabs.some((tab) => tab.sessionId === activeTab.sourceSessionId)
|
||||
? activeTab.sourceSessionId
|
||||
: (persistableTabs[0]?.sessionId ?? null)
|
||||
const data: TabPersistence = {
|
||||
openTabs: persistableTabs.map((t) => ({
|
||||
sessionId: t.sessionId,
|
||||
@ -295,9 +323,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
type: t.type,
|
||||
...(t.traceSessionId ? { traceSessionId: t.traceSessionId } : {}),
|
||||
})),
|
||||
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
|
||||
? activeTabId
|
||||
: (persistableTabs[0]?.sessionId ?? null),
|
||||
activeTabId: persistedActiveTabId,
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatWorkspaceReferenceLocation,
|
||||
formatWorkspaceReferencePrompt,
|
||||
useWorkspaceChatContextStore,
|
||||
} from './workspaceChatContextStore'
|
||||
@ -53,6 +54,54 @@ describe('workspaceChatContextStore', () => {
|
||||
expect(prompt).not.toContain('Path: /repo/src/App.tsx')
|
||||
})
|
||||
|
||||
it('formats diff comments with their source side while preserving ordinary locations', () => {
|
||||
const diffReference = {
|
||||
id: 'diff-ref-1',
|
||||
kind: 'code-comment' as const,
|
||||
path: 'src/a.ts',
|
||||
name: 'a.ts',
|
||||
lineStart: 11,
|
||||
lineEnd: 12,
|
||||
diffSide: 'new' as const,
|
||||
hunkId: 'hunk-1',
|
||||
note: 'Use a shared helper',
|
||||
quote: 'const result = buildResult()\nreturn result',
|
||||
}
|
||||
const ordinaryReference = {
|
||||
...diffReference,
|
||||
id: 'ordinary-ref-1',
|
||||
diffSide: undefined,
|
||||
hunkId: undefined,
|
||||
}
|
||||
|
||||
expect(formatWorkspaceReferenceLocation(diffReference)).toBe('src/a.ts:new:L11-L12')
|
||||
expect(formatWorkspaceReferencePrompt([diffReference])).toContain('src/a.ts:new:L11-L12')
|
||||
expect(formatWorkspaceReferencePrompt([diffReference])).toContain('Comment: Use a shared helper')
|
||||
expect(formatWorkspaceReferenceLocation(ordinaryReference)).toBe('src/a.ts:L11-L12')
|
||||
})
|
||||
|
||||
it('deduplicates diff comments only when side and hunk also match', () => {
|
||||
const store = useWorkspaceChatContextStore.getState()
|
||||
const baseReference = {
|
||||
kind: 'code-comment' as const,
|
||||
path: 'src/a.ts',
|
||||
name: 'a.ts',
|
||||
lineStart: 11,
|
||||
lineEnd: 12,
|
||||
note: 'Use a shared helper',
|
||||
quote: 'return result',
|
||||
}
|
||||
|
||||
store.addReference('session-diff', { ...baseReference, diffSide: 'new', hunkId: 'hunk-1' })
|
||||
store.addReference('session-diff', { ...baseReference, diffSide: 'new', hunkId: 'hunk-1' })
|
||||
store.addReference('session-diff', { ...baseReference, diffSide: 'old', hunkId: 'hunk-1' })
|
||||
store.addReference('session-diff', { ...baseReference, diffSide: 'new', hunkId: 'hunk-2' })
|
||||
|
||||
const references = useWorkspaceChatContextStore.getState().referencesBySession['session-diff'] ?? []
|
||||
expect(references).toHaveLength(3)
|
||||
expect(new Set(references.map((reference) => reference.id))).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('formats selected code without requiring a comment', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
|
||||
@ -11,6 +11,8 @@ export type WorkspaceChatReference = {
|
||||
isDirectory?: boolean
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
diffSide?: 'old' | 'new'
|
||||
hunkId?: string
|
||||
note?: string
|
||||
quote?: string
|
||||
sourceRole?: 'user' | 'assistant'
|
||||
@ -33,7 +35,10 @@ function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
|
||||
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
|
||||
: reference.messageId ?? 'file'
|
||||
const notePart = (reference.note?.trim() || reference.quote?.trim() || '').slice(0, 48)
|
||||
return `${reference.kind}:${reference.path}:${linePart}:${notePart}`
|
||||
const diffPart = reference.kind === 'code-comment'
|
||||
? `${reference.diffSide ?? ''}:${reference.hunkId ?? ''}:`
|
||||
: ''
|
||||
return `${reference.kind}:${reference.path}:${diffPart}${linePart}:${notePart}`
|
||||
}
|
||||
|
||||
function getReferenceDedupKey(reference: WorkspaceChatReference) {
|
||||
@ -45,6 +50,8 @@ function getReferenceDedupKey(reference: WorkspaceChatReference) {
|
||||
reference.sourceRole ?? '',
|
||||
reference.lineStart ?? '',
|
||||
reference.lineEnd ?? '',
|
||||
reference.kind === 'code-comment' ? reference.diffSide ?? '' : '',
|
||||
reference.kind === 'code-comment' ? reference.hunkId ?? '' : '',
|
||||
reference.note?.trim() ?? '',
|
||||
reference.quote?.trim() ?? '',
|
||||
].join(':')
|
||||
@ -58,7 +65,8 @@ export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReferen
|
||||
const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart
|
||||
? `-L${reference.lineEnd}`
|
||||
: ''
|
||||
return `${reference.path}:L${reference.lineStart}${lineEnd}`
|
||||
const side = reference.diffSide ? `:${reference.diffSide}` : ''
|
||||
return `${reference.path}${side}:L${reference.lineStart}${lineEnd}`
|
||||
}
|
||||
|
||||
function getFenceForQuote(quote: string) {
|
||||
|
||||
@ -92,6 +92,29 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MAX_WIDTH)
|
||||
})
|
||||
|
||||
it('keeps a preview opener origin at session scope after the originating card unmounts', async () => {
|
||||
mocks.getWorkspaceDiffMock.mockResolvedValue({ state: 'ok', diff: '' })
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-origin', 'src/a.ts', 'diff', {
|
||||
sourceTurnKey: 'assistant-message-4',
|
||||
sourceElementId: 'turn-change-opener-message-4-a',
|
||||
})
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getOrigin('session-origin')).toEqual({
|
||||
sourceTurnKey: 'assistant-message-4',
|
||||
sourceElementId: 'turn-change-opener-message-4-a',
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().closePanel('session-origin')
|
||||
expect(useWorkspacePanelStore.getState().getOrigin('session-origin')).toEqual({
|
||||
sourceTurnKey: 'assistant-message-4',
|
||||
sourceElementId: 'turn-change-opener-message-4-a',
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().clearOrigin('session-origin')
|
||||
expect(useWorkspacePanelStore.getState().getOrigin('session-origin')).toBeNull()
|
||||
})
|
||||
|
||||
it('loads workspace status successfully', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
@ -419,6 +442,102 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-refresh']).toBe('diff:src/a.ts')
|
||||
})
|
||||
|
||||
it('keeps the last successful preview payload while a refresh is pending and after it fails', async () => {
|
||||
const refresh = deferred<{ state: 'ok'; path: string; diff: string }>()
|
||||
mocks.getWorkspaceDiffMock
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@\n-old\n+cached',
|
||||
})
|
||||
.mockReturnValueOnce(refresh.promise)
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-stale-refresh', 'src/a.ts', 'diff')
|
||||
const refreshPromise = useWorkspacePanelStore.getState().openPreview('session-stale-refresh', 'src/a.ts', 'diff')
|
||||
const previewKey = 'session-stale-refresh::diff:src/a.ts'
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-stale-refresh']).toEqual([
|
||||
expect.objectContaining({ state: 'ok', diff: '@@ -1 +1 @@\n-old\n+cached' }),
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBe(true)
|
||||
|
||||
refresh.reject(new Error('refresh failed'))
|
||||
await refreshPromise
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-stale-refresh']).toEqual([
|
||||
expect.objectContaining({ state: 'ok', diff: '@@ -1 +1 @@\n-old\n+cached' }),
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBe('refresh failed')
|
||||
})
|
||||
|
||||
it('keeps the last successful file payload and records structured state for a non-ok refresh result', async () => {
|
||||
mocks.getWorkspaceFileMock
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
content: 'cached file',
|
||||
language: 'typescript',
|
||||
size: 11,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: 'error',
|
||||
path: 'src/a.ts',
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-file-refresh-error', 'src/a.ts', 'file')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-file-refresh-error', 'src/a.ts', 'file')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-file-refresh-error']).toEqual([
|
||||
expect.objectContaining({ state: 'ok', content: 'cached file', language: 'typescript' }),
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-file-refresh-error::file:src/a.ts'])
|
||||
.toBeNull()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-file-refresh-error::file:src/a.ts'])
|
||||
.toBe('error')
|
||||
})
|
||||
|
||||
it('keeps the last successful diff payload and records structured state for a non-ok refresh result', async () => {
|
||||
mocks.getWorkspaceDiffMock
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@\n-old\n+cached',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: 'missing',
|
||||
path: 'src/a.ts',
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-diff-refresh-missing', 'src/a.ts', 'diff')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-diff-refresh-missing', 'src/a.ts', 'diff')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-diff-refresh-missing']).toEqual([
|
||||
expect.objectContaining({ state: 'ok', diff: '@@ -1 +1 @@\n-old\n+cached' }),
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-diff-refresh-missing::diff:src/a.ts'])
|
||||
.toBeNull()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-diff-refresh-missing::diff:src/a.ts'])
|
||||
.toBe('missing')
|
||||
})
|
||||
|
||||
it('keeps first-load non-ok state on the tab without marking it as a refresh failure', async () => {
|
||||
mocks.getWorkspaceDiffMock.mockResolvedValueOnce({
|
||||
state: 'missing',
|
||||
path: 'src/missing.ts',
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-initial-missing', 'src/missing.ts', 'diff')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-initial-missing']).toEqual([
|
||||
expect.objectContaining({ state: 'missing' }),
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-initial-missing::diff:src/missing.ts'])
|
||||
.toBeNull()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-initial-missing::diff:src/missing.ts'])
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('closes exact tab id and preserves sibling preview for the same path', async () => {
|
||||
mocks.getWorkspaceFileMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
@ -515,6 +634,11 @@ describe('workspacePanelStore', () => {
|
||||
'session-preview-scope::file:c.ts': 'loading',
|
||||
'session-preview-scope::file:d.ts': 'loading',
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
...state.errors.previewRefreshStateByTabId,
|
||||
'session-preview-scope::file:c.ts': 'missing',
|
||||
'session-preview-scope::file:d.ts': 'error',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -527,6 +651,8 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
|
||||
expect(useWorkspacePanelStore.getState().loading.previewByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'others')
|
||||
|
||||
@ -729,6 +855,10 @@ describe('workspacePanelStore', () => {
|
||||
'session-clear::file:src/a.ts': 'bad',
|
||||
'session-reset::file:src/b.ts': 'bad',
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
'session-clear::file:src/a.ts': 'missing',
|
||||
'session-reset::file:src/b.ts': 'error',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -762,5 +892,7 @@ describe('workspacePanelStore', () => {
|
||||
expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined()
|
||||
expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
|
||||
expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
|
||||
expect(state.errors.previewRefreshStateByTabId['session-clear::file:src/a.ts']).toBeUndefined()
|
||||
expect(state.errors.previewRefreshStateByTabId['session-reset::file:src/b.ts']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -15,6 +15,10 @@ export type WorkspacePanelView = 'changed' | 'all'
|
||||
export type WorkbenchMode = 'workspace' | 'browser'
|
||||
export type WorkspacePreviewKind = 'file' | 'diff'
|
||||
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
|
||||
export type WorkspacePanelOrigin = {
|
||||
sourceTurnKey: string
|
||||
sourceElementId: string
|
||||
}
|
||||
export type WorkspacePreviewState =
|
||||
| 'loading'
|
||||
| WorkspaceReadFileResult['state']
|
||||
@ -52,6 +56,7 @@ type WorkspacePanelErrorState = {
|
||||
statusBySession: Record<string, string | null | undefined>
|
||||
treeBySessionPath: Record<string, string | null | undefined>
|
||||
previewByTabId: Record<string, string | null | undefined>
|
||||
previewRefreshStateByTabId: Record<string, WorkspacePreviewState | null | undefined>
|
||||
}
|
||||
|
||||
type WorkspacePanelStore = {
|
||||
@ -63,12 +68,15 @@ type WorkspacePanelStore = {
|
||||
treeBySessionPath: Record<string, Record<string, WorkspaceTreeResult | undefined> | undefined>
|
||||
previewTabsBySession: Record<string, WorkspacePreviewTab[] | undefined>
|
||||
activePreviewTabIdBySession: Record<string, string | null | undefined>
|
||||
originBySession: Record<string, WorkspacePanelOrigin | undefined>
|
||||
loading: WorkspacePanelLoadingState
|
||||
errors: WorkspacePanelErrorState
|
||||
|
||||
isPanelOpen: (sessionId: string) => boolean
|
||||
getActiveView: (sessionId: string) => WorkspacePanelView
|
||||
getMode: (sessionId: string) => WorkbenchMode
|
||||
getOrigin: (sessionId: string) => WorkspacePanelOrigin | null
|
||||
clearOrigin: (sessionId: string) => void
|
||||
setMode: (sessionId: string, mode: WorkbenchMode) => void
|
||||
openPanel: (sessionId: string) => void
|
||||
closePanel: (sessionId: string) => void
|
||||
@ -78,7 +86,7 @@ type WorkspacePanelStore = {
|
||||
loadStatus: (sessionId: string) => Promise<void>
|
||||
loadTree: (sessionId: string, path?: string) => Promise<void>
|
||||
toggleTreeNode: (sessionId: string, path: string) => Promise<void>
|
||||
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise<void>
|
||||
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind, origin?: WorkspacePanelOrigin) => Promise<void>
|
||||
closePreview: (sessionId: string, tabId: string) => void
|
||||
closePreviewTabs: (sessionId: string, tabId: string, scope: WorkspacePreviewCloseScope) => void
|
||||
clearSession: (sessionId: string) => void
|
||||
@ -195,6 +203,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
treeBySessionPath: {},
|
||||
previewTabsBySession: {},
|
||||
activePreviewTabIdBySession: {},
|
||||
originBySession: {},
|
||||
loading: {
|
||||
statusBySession: {},
|
||||
treeBySessionPath: {},
|
||||
@ -204,11 +213,16 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
statusBySession: {},
|
||||
treeBySessionPath: {},
|
||||
previewByTabId: {},
|
||||
previewRefreshStateByTabId: {},
|
||||
},
|
||||
|
||||
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
|
||||
getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView,
|
||||
getMode: (sessionId) => get().modeBySession[sessionId] ?? DEFAULT_WORKBENCH_MODE,
|
||||
getOrigin: (sessionId) => get().originBySession[sessionId] ?? null,
|
||||
clearOrigin: (sessionId) => set((state) => ({
|
||||
originBySession: removeRecordKey(state.originBySession, sessionId),
|
||||
})),
|
||||
|
||||
setMode: (sessionId, mode) =>
|
||||
set((state) => ({
|
||||
@ -446,13 +460,21 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
openPreview: async (sessionId, path, kind) => {
|
||||
openPreview: async (sessionId, path, kind, origin) => {
|
||||
// Ensure the workspace panel is visible — openPreview is now triggered from places
|
||||
// where the panel may be closed (e.g. the chat "打开方式" menu / turn-changes card),
|
||||
// not only from inside the already-open file tree. Opening a file always switches the
|
||||
// unified workbench into file ("workspace") mode.
|
||||
get().openPanel(sessionId)
|
||||
get().setMode(sessionId, 'workspace')
|
||||
if (origin) {
|
||||
set((state) => ({
|
||||
originBySession: {
|
||||
...state.originBySession,
|
||||
[sessionId]: origin,
|
||||
},
|
||||
}))
|
||||
}
|
||||
const tabId = getWorkspacePreviewTabId(path, kind)
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
|
||||
@ -478,6 +500,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: null,
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
...state.errors.previewRefreshStateByTabId,
|
||||
[requestKey]: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
@ -511,6 +537,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: null,
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
...state.errors.previewRefreshStateByTabId,
|
||||
[requestKey]: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
@ -523,18 +553,22 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
const current = tabs.find((tab) => tab.id === tabId)
|
||||
const preserveSuccessfulPayload = current?.state === 'ok' && result.state !== 'ok'
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
|
||||
...current,
|
||||
diff: result.diff ?? '',
|
||||
content: undefined,
|
||||
language: undefined,
|
||||
size: undefined,
|
||||
state: result.state,
|
||||
error: result.error,
|
||||
})),
|
||||
[sessionId]: preserveSuccessfulPayload
|
||||
? tabs
|
||||
: upsertPreviewTab(tabs, tabId, (tab) => ({
|
||||
...tab,
|
||||
diff: result.diff ?? '',
|
||||
content: undefined,
|
||||
language: undefined,
|
||||
size: undefined,
|
||||
state: result.state,
|
||||
error: result.error,
|
||||
})),
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
@ -549,6 +583,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: result.error ?? null,
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
...state.errors.previewRefreshStateByTabId,
|
||||
[requestKey]: preserveSuccessfulPayload ? result.state : null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -561,21 +599,25 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
const current = tabs.find((tab) => tab.id === tabId)
|
||||
const preserveSuccessfulPayload = current?.state === 'ok' && result.state !== 'ok'
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
|
||||
...current,
|
||||
content: result.content,
|
||||
dataUrl: result.dataUrl,
|
||||
mimeType: result.mimeType,
|
||||
previewType: result.previewType ?? 'text',
|
||||
diff: undefined,
|
||||
language: result.language,
|
||||
size: result.size,
|
||||
state: result.state,
|
||||
error: result.error,
|
||||
})),
|
||||
[sessionId]: preserveSuccessfulPayload
|
||||
? tabs
|
||||
: upsertPreviewTab(tabs, tabId, (tab) => ({
|
||||
...tab,
|
||||
content: result.content,
|
||||
dataUrl: result.dataUrl,
|
||||
mimeType: result.mimeType,
|
||||
previewType: result.previewType ?? 'text',
|
||||
diff: undefined,
|
||||
language: result.language,
|
||||
size: result.size,
|
||||
state: result.state,
|
||||
error: result.error,
|
||||
})),
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
@ -590,6 +632,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: result.error ?? null,
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
...state.errors.previewRefreshStateByTabId,
|
||||
[requestKey]: preserveSuccessfulPayload ? result.state : null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -600,15 +646,19 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
const message = error instanceof Error ? error.message : 'Failed to load workspace preview'
|
||||
const current = tabs.find((tab) => tab.id === tabId)
|
||||
const preserveSuccessfulPayload = current?.state === 'ok'
|
||||
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
|
||||
...current,
|
||||
state: 'error',
|
||||
error: message,
|
||||
})),
|
||||
[sessionId]: preserveSuccessfulPayload
|
||||
? tabs
|
||||
: upsertPreviewTab(tabs, tabId, (tab) => ({
|
||||
...tab,
|
||||
state: 'error',
|
||||
error: message,
|
||||
})),
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
@ -623,6 +673,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: message,
|
||||
},
|
||||
previewRefreshStateByTabId: {
|
||||
...state.errors.previewRefreshStateByTabId,
|
||||
[requestKey]: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -648,6 +702,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
|
||||
previewRefreshStateByTabId: removeRecordKey(state.errors.previewRefreshStateByTabId, requestKey),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -715,6 +770,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: removeRecordKeys(state.errors.previewByTabId, requestKeys),
|
||||
previewRefreshStateByTabId: removeRecordKeys(state.errors.previewRefreshStateByTabId, requestKeys),
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -733,6 +789,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),
|
||||
previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId),
|
||||
activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId),
|
||||
originBySession: removeRecordKey(state.originBySession, sessionId),
|
||||
loading: {
|
||||
statusBySession: removeRecordKey(state.loading.statusBySession, sessionId),
|
||||
treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId),
|
||||
@ -742,6 +799,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
statusBySession: removeRecordKey(state.errors.statusBySession, sessionId),
|
||||
treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId),
|
||||
previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId),
|
||||
previewRefreshStateByTabId: stripSessionKeys(state.errors.previewRefreshStateByTabId, sessionId),
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user