fix(desktop): support Shift range comments in files #1004

This commit is contained in:
程序员阿江(Relakkes) 2026-07-16 18:29:16 +08:00
parent c06f958e7c
commit b2db55afc8
7 changed files with 150 additions and 29 deletions

View File

@ -2247,6 +2247,82 @@ describe('WorkspacePanel', () => {
])
})
it('adds a Shift-selected line range comment from a code preview to the chat context', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-line-range-comment': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-line-range-comment': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-line-range-comment': [{
id: 'file:src/App.tsx',
path: 'src/App.tsx',
kind: 'file',
title: 'App.tsx',
language: 'tsx',
content: 'const title = "Todo"\nconst count = 1\nexport default title',
state: 'ok',
size: 64,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-line-range-comment': 'file:src/App.tsx',
},
}))
const view = await renderPanel('session-line-range-comment')
const firstLine = view.getByRole('button', { name: 'Comment line 1' })
const secondLine = view.getByRole('button', { name: 'Comment line 2' })
const thirdLine = view.getByRole('button', { name: 'Comment line 3' })
await clickElement(firstLine)
await act(async () => {
fireEvent.click(thirdLine, { shiftKey: true })
await Promise.resolve()
})
expect(firstLine.getAttribute('aria-pressed')).toBe('true')
expect(secondLine.getAttribute('aria-pressed')).toBe('true')
expect(thirdLine.getAttribute('aria-pressed')).toBe('true')
expect(view.getByText('Lines 13')).toBeTruthy()
const textarea = view.getByPlaceholderText('Describe what should change here...')
await act(() => {
fireEvent.change(textarea, { target: { value: 'Extract this setup' } })
})
await clickElement(view.getByRole('button', { name: 'Add comment' }))
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-range-comment']).toMatchObject([
{
kind: 'code-comment',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
lineStart: 1,
lineEnd: 3,
note: 'Extract this setup',
quote: 'const title = "Todo"\nconst count = 1\nexport default title',
},
])
})
it('adds a side-aware diff comment, keeps the diff open, and focuses the composer', async () => {
await setWorkspaceState((state) => ({
...state,

View File

@ -532,26 +532,30 @@ function CodeSurface({
}: {
value: string
language: string
onAddLineComment: (line: number, note: string, quote: string) => void
onAddLineComment: (lineStart: number, lineEnd: number, note: string, quote: string) => void
onAddSelection: (selection: WorkspaceTextSelection) => void
}) {
const t = useTranslation()
const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [commentLine, setCommentLine] = useState<number | null>(null)
const [commentRange, setCommentRange] = useState<{ anchorLine: number; focusLine: number } | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const [showAllLines, setShowAllLines] = useState(false)
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
const [shikiTokensByLine, setShikiTokensByLine] = useState<WorkspaceDiffHighlightToken[][] | null>(null)
const lines = value.split('\n')
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
const commentLineStart = commentRange ? Math.min(commentRange.anchorLine, commentRange.focusLine) : null
const commentLineEnd = commentRange ? Math.max(commentRange.anchorLine, commentRange.focusLine) : null
const activeQuote = commentLineStart && commentLineEnd
? visibleLines.slice(commentLineStart - 1, commentLineEnd).join('\n')
: ''
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PREVIEW_LINE_LIMIT
const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n')
useEffect(() => {
setShowAllLines(false)
setCommentLine(null)
setCommentRange(null)
setCommentDraft('')
setSelectionMenu(null)
}, [language, value])
@ -588,9 +592,9 @@ function CodeSurface({
})
const submitLineComment = () => {
if (!commentLine || !commentDraft.trim()) return
onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
setCommentLine(null)
if (!commentLineStart || !commentLineEnd || !commentDraft.trim()) return
onAddLineComment(commentLineStart, commentLineEnd, commentDraft.trim(), activeQuote)
setCommentRange(null)
setCommentDraft('')
}
@ -619,7 +623,7 @@ function CodeSurface({
}
const renderLineCommentEditor = (lineNumber: number) => {
if (commentLine !== lineNumber) return null
if (!commentLineStart || commentLineEnd !== lineNumber) return null
return (
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
@ -629,7 +633,9 @@ function CodeSurface({
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
{t('workspace.commentLineTarget', { line: lineNumber })}
{commentLineStart === commentLineEnd
? t('workspace.commentLineTarget', { line: commentLineStart })
: t('workspace.commentLineRangeTarget', { start: commentLineStart, end: commentLineEnd })}
</span>
</div>
<textarea
@ -644,7 +650,7 @@ function CodeSurface({
<button
type="button"
onClick={() => {
setCommentLine(null)
setCommentRange(null)
setCommentDraft('')
}}
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
@ -665,20 +671,46 @@ function CodeSurface({
)
}
const renderLineNumberButton = (lineNumber: number) => (
<button
type="button"
aria-label={t('workspace.commentLine', { line: lineNumber })}
onClick={() => {
setCommentLine(lineNumber)
setCommentDraft('')
}}
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
>
{lineNumber}
</button>
const isCommentLineSelected = (lineNumber: number) => (
commentLineStart !== null
&& commentLineEnd !== null
&& lineNumber >= commentLineStart
&& lineNumber <= commentLineEnd
)
const lineRowClassName = (lineNumber: number) => (
`group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 ${
isCommentLineSelected(lineNumber)
? 'bg-[var(--color-info-container)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`
)
const renderLineNumberButton = (lineNumber: number) => {
const selected = isCommentLineSelected(lineNumber)
return (
<button
type="button"
aria-label={t('workspace.commentLine', { line: lineNumber })}
aria-pressed={selected}
onClick={(event) => {
const extendRange = event.shiftKey && commentRange !== null
setCommentRange(extendRange
? { ...commentRange, focusLine: lineNumber }
: { anchorLine: lineNumber, focusLine: lineNumber })
if (!extendRange) setCommentDraft('')
}}
className={`select-none text-right text-[11px] transition-colors focus-visible:outline-none ${
selected
? 'font-semibold text-[var(--color-info)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-brand)] focus-visible:text-[var(--color-brand)]'
}`}
>
{lineNumber}
</button>
)
}
return (
<div
ref={surfaceRef}
@ -701,7 +733,7 @@ function CodeSurface({
return (
<div key={lineNumber}>
<div
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
className={lineRowClassName(lineNumber)}
data-workspace-line-number={lineNumber}
>
{renderLineNumberButton(lineNumber)}
@ -726,7 +758,7 @@ function CodeSurface({
<div key={lineNumber}>
<div
data-workspace-line-number={lineNumber}
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
className={lineRowClassName(lineNumber)}
>
{renderLineNumberButton(lineNumber)}
<span className="whitespace-pre pr-6">
@ -768,7 +800,7 @@ function CodeSurface({
<div
{...lineProps}
data-workspace-line-number={lineNumber}
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
className={lineRowClassName(lineNumber)}
>
{renderLineNumberButton(lineNumber)}
<span className="whitespace-pre pr-6">
@ -1409,14 +1441,20 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
})
}
const addLineCommentToChat = (path: string, line: number, note: string, quote: string) => {
const addLineCommentToChat = (
path: string,
lineStart: number,
lineEnd: number,
note: string,
quote: string,
) => {
addWorkspaceReference(sessionId, {
kind: 'code-comment',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: path.split('/').pop() || path,
lineStart: line,
lineEnd: line,
lineStart,
lineEnd,
note,
quote,
})
@ -1776,7 +1814,9 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
<CodeSurface
value={activePreviewTab.content ?? ''}
language={activePreviewTab.language ?? 'text'}
onAddLineComment={(line, note, quote) => addLineCommentToChat(activePreviewTab.path, line, note, quote)}
onAddLineComment={(lineStart, lineEnd, note, quote) => (
addLineCommentToChat(activePreviewTab.path, lineStart, lineEnd, note, quote)
)}
onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)}
/>
) : (

View File

@ -205,6 +205,7 @@ export const en = {
'workspace.localComment': 'Local comment',
'workspace.commentLine': 'Comment line {line}',
'workspace.commentLineTarget': 'Line {line}',
'workspace.commentLineRangeTarget': 'Lines {start}{end}',
'workspace.commentPlaceholder': 'Describe what should change here...',
'workspace.addCommentToChat': 'Add comment',
'workspace.diffReview.commentLineAria': 'Comment on {path} {side} line {line}',

View File

@ -207,6 +207,7 @@ export const jp: Record<TranslationKey, string> = {
'workspace.localComment': 'ローカルコメント',
'workspace.commentLine': '{line} 行目にコメント',
'workspace.commentLineTarget': '{line} 行目',
'workspace.commentLineRangeTarget': '{start}{end} 行目',
'workspace.commentPlaceholder': 'ここで何を変更すべきか説明してください...',
'workspace.addCommentToChat': 'コメントを追加',
'workspace.diffReview.commentLineAria': '{path} の{side}側 {line} 行目にコメント',

View File

@ -207,6 +207,7 @@ export const kr: Record<TranslationKey, string> = {
'workspace.localComment': '로컬 댓글',
'workspace.commentLine': '{line}번째 줄에 댓글',
'workspace.commentLineTarget': '{line}번째 줄',
'workspace.commentLineRangeTarget': '{start}~{end}번째 줄',
'workspace.commentPlaceholder': '여기서 무엇을 변경해야 하는지 설명하세요...',
'workspace.addCommentToChat': '댓글 추가',
'workspace.diffReview.commentLineAria': '{path}의 {side} 쪽 {line}번 줄에 댓글 달기',

View File

@ -207,6 +207,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.localComment': '本地評論',
'workspace.commentLine': '評論第 {line} 行',
'workspace.commentLineTarget': '第 {line} 行',
'workspace.commentLineRangeTarget': '第 {start}{end} 行',
'workspace.commentPlaceholder': '描述你希望這裡怎麼改...',
'workspace.addCommentToChat': '新增評論',
'workspace.diffReview.commentLineAria': '評論 {path} 的{side}側第 {line} 行',

View File

@ -207,6 +207,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.localComment': '本地评论',
'workspace.commentLine': '评论第 {line} 行',
'workspace.commentLineTarget': '第 {line} 行',
'workspace.commentLineRangeTarget': '第 {start}{end} 行',
'workspace.commentPlaceholder': '描述你希望这里怎么改...',
'workspace.addCommentToChat': '添加评论',
'workspace.diffReview.commentLineAria': '评论 {path} 的{side}侧第 {line} 行',