✨ feat(vscode): 「改动」面板加审查勾 + deepseek 增量生成文件改动说明
右栏 diff 头部加一组按钮:
- 审查对勾:与左树同一状态源,勾上则文件路径标题置灰
- ✨ 一键调 deepseek 生成该文件「一句话改动目的」
增量复用会话:首个文件开新 cc 会话并把 sessionId 记到
.spx/pr-file-summaries.json,后续文件 --resume 接上去,省去每次重新
理解 PR 的开销;resume 失败则丢弃旧 id 重开一次。说明也落盘(跨 push
保留),重载后仍显示。
- spawnClaude 增 resumeSessionId(--resume)
- 新增 prFileSummaryStore;handler handleGeneratePrFileSummary
用 jsdiff 拼 unified diff 喂模型
- 依赖 diff(jsdiff)
This commit is contained in:
@@ -36,8 +36,11 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) {
|
||||
loading,
|
||||
confirmed,
|
||||
diffByPath,
|
||||
summariesByPath,
|
||||
generatingPath,
|
||||
getFileDiff,
|
||||
setConfirmed,
|
||||
generateSummary,
|
||||
} = usePrFiles(issueNumber)
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string | undefined>(undefined)
|
||||
@@ -150,6 +153,11 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) {
|
||||
path={selectedPath}
|
||||
status={selectedFile?.status}
|
||||
diff={selectedPath ? diffByPath[selectedPath] : undefined}
|
||||
confirmed={!!selectedPath && confirmedSet.has(selectedPath)}
|
||||
onToggleConfirm={next => selectedPath && onToggleFileConfirm(selectedPath, next)}
|
||||
summary={selectedPath ? summariesByPath[selectedPath] : undefined}
|
||||
generating={!!selectedPath && generatingPath === selectedPath}
|
||||
onGenerate={() => selectedPath && generateSummary(selectedPath, selectedFile?.previousFilename)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { FileDiff } from '../../hooks/usePrFiles'
|
||||
import { generateDiffFile } from '@git-diff-view/file'
|
||||
import { DiffModeEnum, DiffView } from '@git-diff-view/react'
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { statusBadge, statusColorVar } from './fileTree'
|
||||
import '@git-diff-view/react/styles/diff-view.css'
|
||||
@@ -22,6 +23,16 @@ interface FileDiffPaneProps {
|
||||
status: string | undefined
|
||||
/** 该文件的 diff 内容;undefined 表示请求中(加载态)。 */
|
||||
diff: FileDiff | undefined
|
||||
/** 当前文件是否已审查(已查看);与左树同一状态源。 */
|
||||
confirmed: boolean
|
||||
/** 切换当前文件审查态。 */
|
||||
onToggleConfirm: (next: boolean) => void
|
||||
/** 当前文件已生成的一句话改动说明;undefined 表示未生成。 */
|
||||
summary: string | undefined
|
||||
/** 是否正在为当前文件生成说明。 */
|
||||
generating: boolean
|
||||
/** 触发 deepseek 生成当前文件说明。 */
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
/** 读当前 VS Code 主题明暗:body 带 vscode-light / vscode-high-contrast-light 即浅色,否则深色。 */
|
||||
@@ -42,7 +53,7 @@ function Centered({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) {
|
||||
export function FileDiffPane({ path, status, diff, confirmed, onToggleConfirm, summary, generating, onGenerate }: FileDiffPaneProps) {
|
||||
const [mode, setMode] = useState<DiffModeEnum>(DiffModeEnum.Split)
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind())
|
||||
|
||||
@@ -82,35 +93,71 @@ export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 右栏标题条:文件路径 + 状态徽标 + Split/Unified 切换 */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--vscode-panel-border)] px-2 py-1">
|
||||
{status && (
|
||||
<span
|
||||
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||||
style={{ color: statusColorVar(status) }}
|
||||
>
|
||||
{statusBadge(status)}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-[var(--vscode-foreground)]" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<div className="flex shrink-0 overflow-hidden rounded border border-[var(--vscode-panel-border)] text-[10px]">
|
||||
{([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-1.5 py-0.5 transition-colors ${
|
||||
mode === m
|
||||
? 'bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)]'
|
||||
: 'text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]'
|
||||
}`}
|
||||
{/* 右栏标题条:文件路径 + 审查勾 + 一键生成说明 + Split/Unified 切换;下方可选一行说明 */}
|
||||
<div className="flex shrink-0 flex-col gap-1 border-b border-[var(--vscode-panel-border)] px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{status && (
|
||||
<span
|
||||
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||||
style={{ color: statusColorVar(status) }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{statusBadge(status)}
|
||||
</span>
|
||||
)}
|
||||
{/* 已审查则标题置灰 */}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-xs"
|
||||
style={{ color: confirmed ? 'var(--vscode-descriptionForeground)' : 'var(--vscode-foreground)' }}
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
{/* 审查勾:与左树同一状态源,点击联动 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleConfirm(!confirmed)}
|
||||
title={confirmed ? '已审查,点击取消' : '标记为已审查'}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
color: confirmed
|
||||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||
: 'var(--vscode-descriptionForeground)',
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
{/* 一键调 deepseek 生成本文件改动说明 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={generating}
|
||||
title={generating ? '生成中…' : '用 deepseek 生成本文件改动说明'}
|
||||
className="shrink-0 text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] disabled:opacity-60"
|
||||
>
|
||||
{generating ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
</button>
|
||||
<div className="flex shrink-0 overflow-hidden rounded border border-[var(--vscode-panel-border)] text-[10px]">
|
||||
{([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-1.5 py-0.5 transition-colors ${
|
||||
mode === m
|
||||
? 'bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)]'
|
||||
: 'text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(generating || summary) && (
|
||||
<div className="whitespace-pre-wrap break-words text-[11px] leading-snug text-[var(--vscode-descriptionForeground)]">
|
||||
{generating ? '正在生成说明…' : summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
|
||||
@@ -31,8 +31,13 @@ export interface UsePrFilesResult {
|
||||
confirmed: string[]
|
||||
/** key ∈ diffByPath 即「已请求/已到」;值为内容。仅 key 在但值 undefined 不会出现。 */
|
||||
diffByPath: Record<string, FileDiff>
|
||||
/** 路径 → 已生成的一句话改动说明(deepseek 增量生成、跨 push 保留)。 */
|
||||
summariesByPath: Record<string, string>
|
||||
/** 正在生成说明的文件路径;null 表示无生成中。 */
|
||||
generatingPath: string | null
|
||||
getFileDiff: (path: string, previousPath?: string) => void
|
||||
setConfirmed: (paths: string[]) => void
|
||||
generateSummary: (path: string, previousPath?: string) => void
|
||||
}
|
||||
|
||||
export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||
@@ -42,6 +47,8 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirmed, setConfirmedState] = useState<string[]>([])
|
||||
const [diffByPath, setDiffByPath] = useState<Record<string, FileDiff>>({})
|
||||
const [summariesByPath, setSummariesByPath] = useState<Record<string, string>>({})
|
||||
const [generatingPath, setGeneratingPath] = useState<string | null>(null)
|
||||
|
||||
// issueNumber / headSha 装进 ref,让订阅闭包与回调始终读到当前值。
|
||||
const issueRef = useRef<number | undefined>(issueNumber)
|
||||
@@ -60,10 +67,20 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||
setFiles(msg.files)
|
||||
setHeadSha(msg.headSha)
|
||||
setConfirmedState(msg.confirmed ?? [])
|
||||
setSummariesByPath(msg.summaries ?? {})
|
||||
setFilesError(msg.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-file-summary/show') {
|
||||
if (msg.issueNumber !== issueRef.current)
|
||||
return
|
||||
// 生成结束(成功带 summary / 失败带 error)都解除该文件的 loading;失败的 error 已由扩展侧 toast。
|
||||
setGeneratingPath(p => (p === msg.path ? null : p))
|
||||
if (msg.summary)
|
||||
setSummariesByPath(prev => ({ ...prev, [msg.path]: msg.summary! }))
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pr-file-diff/show') {
|
||||
if (msg.issueNumber !== issueRef.current)
|
||||
return
|
||||
@@ -89,6 +106,8 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||
setFilesError(undefined)
|
||||
setConfirmedState([])
|
||||
setDiffByPath({})
|
||||
setSummariesByPath({})
|
||||
setGeneratingPath(null)
|
||||
requestedRef.current = new Set()
|
||||
if (issueNumber === undefined) {
|
||||
setLoading(false)
|
||||
@@ -117,6 +136,15 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||
postMessage({ type: 'pr-review/set', issueNumber: num, headSha: headShaRef.current, confirmed: paths })
|
||||
}, [])
|
||||
|
||||
// 触发 deepseek 增量生成该文件的改动说明;置 loading,结果经 pr-file-summary/show 回来。
|
||||
const generateSummary = useCallback((path: string, previousPath?: string): void => {
|
||||
const num = issueRef.current
|
||||
if (num === undefined)
|
||||
return
|
||||
setGeneratingPath(path)
|
||||
postMessage({ type: 'pr-file-summary/generate', issueNumber: num, path, previousPath })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
files,
|
||||
headSha,
|
||||
@@ -124,7 +152,10 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult {
|
||||
loading,
|
||||
confirmed,
|
||||
diffByPath,
|
||||
summariesByPath,
|
||||
generatingPath,
|
||||
getFileDiff,
|
||||
setConfirmed,
|
||||
generateSummary,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +115,9 @@ export type ExtensionToWebview
|
||||
| { type: 'issue/remove', issueNumber: number }
|
||||
| { type: 'profiles/show', data: ProfilesData }
|
||||
| { type: 'managed-sessions/show', data: ManagedSessionsData }
|
||||
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], error?: string }
|
||||
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], summaries: Record<string, string>, error?: string }
|
||||
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
|
||||
| { type: 'pr-file-summary/show', issueNumber: number, path: string, summary?: string, error?: string }
|
||||
|
||||
export type WebviewToExtension
|
||||
= | { type: 'issues/refresh' }
|
||||
@@ -189,4 +190,5 @@ export type WebviewToExtension
|
||||
| { type: 'managed-sessions/close-tab', sessionId: string }
|
||||
| { type: 'pr-files/get', issueNumber: number }
|
||||
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
||||
| { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string }
|
||||
| { type: 'pr-review/set', issueNumber: number, headSha: string, confirmed: string[] }
|
||||
|
||||
Reference in New Issue
Block a user