From 8c5cc90eda4fb7434db56b151c5ee65aab6dc45c Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 29 Jun 2026 15:24:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E3=80=8C=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E3=80=8D=E9=9D=A2=E6=9D=BF=E5=8A=A0=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=8B=BE=20+=20deepseek=20=E5=A2=9E=E9=87=8F=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=94=B9=E5=8A=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 右栏 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) --- vscode/package.json | 4 + vscode/pnpm-lock.yaml | 21 ++++ vscode/src/cc/spawnClaude.ts | 10 +- vscode/src/panel/KanbanPanel.ts | 8 ++ vscode/src/panel/handlers/prFiles.ts | 118 +++++++++++++++++- vscode/src/panel/messages.ts | 4 +- vscode/src/sessions/prFileSummaryStore.ts | 113 +++++++++++++++++ .../src/components/PrFilesPanel.tsx | 8 ++ .../src/components/prFiles/FileDiffPane.tsx | 103 ++++++++++----- vscode/webview-ui/src/hooks/usePrFiles.ts | 31 +++++ vscode/webview-ui/src/lib/messages.ts | 4 +- 11 files changed, 387 insertions(+), 37 deletions(-) create mode 100644 vscode/src/sessions/prFileSummaryStore.ts diff --git a/vscode/package.json b/vscode/package.json index 5ff9f72..c88dd58 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -108,6 +108,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^6.7.3", + "@types/diff": "^8.0.0", "@types/node": "^25.0.3", "@types/vscode": "^1.97.0", "@vscode/vsce": "^3.9.1", @@ -118,5 +119,8 @@ "ovsx": "^0.10.7", "typescript": "^5.9.3", "vitest": "^4.0.16" + }, + "dependencies": { + "diff": "^9.0.0" } } diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index bf5879e..acfd30e 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -12,10 +12,17 @@ patchedDependencies: importers: .: + dependencies: + diff: + specifier: ^9.0.0 + version: 9.0.0 devDependencies: '@antfu/eslint-config': specifier: ^6.7.3 version: 6.7.3(@vue/compiler-sfc@3.4.27)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2)) + '@types/diff': + specifier: ^8.0.0 + version: 8.0.0 '@types/node': specifier: ^25.0.3 version: 25.0.3 @@ -1201,6 +1208,10 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@8.0.0': + resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} + deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1760,6 +1771,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4579,6 +4594,10 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@8.0.0': + dependencies: + diff: 9.0.0 + '@types/estree@1.0.8': {} '@types/hast@3.0.4': @@ -5212,6 +5231,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 diff --git a/vscode/src/cc/spawnClaude.ts b/vscode/src/cc/spawnClaude.ts index 27bbbbb..d271878 100644 --- a/vscode/src/cc/spawnClaude.ts +++ b/vscode/src/cc/spawnClaude.ts @@ -234,14 +234,16 @@ export async function spawnClaude(opts: { images?: ClaudeImage[] profilePath?: string bare?: boolean + /** 续会话:传入则 `--resume `,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */ + resumeSessionId?: string }): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const hasImages = !!opts.images && opts.images.length > 0 if (!hasImages) { - return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare) + return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId) } - return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare) + return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId) } function spawnClaudeText( @@ -250,11 +252,13 @@ function spawnClaudeText( timeoutMs: number, profilePath?: string, bare?: boolean, + resumeSessionId?: string, ): Promise { const args = [ ...(bare ? ['--bare'] : []), '--dangerously-skip-permissions', ...(profilePath ? ['--settings', profilePath] : []), + ...(resumeSessionId ? ['--resume', resumeSessionId] : []), '-p', prompt, '--output-format', @@ -361,6 +365,7 @@ function spawnClaudeStreamed( images: ClaudeImage[], profilePath?: string, bare?: boolean, + resumeSessionId?: string, ): Promise { // Local Claude Code v2.1.143 enforces that --input-format=stream-json must // be paired with --output-format=stream-json (the friendlier `--output-format @@ -370,6 +375,7 @@ function spawnClaudeStreamed( ...(bare ? ['--bare'] : []), '--dangerously-skip-permissions', ...(profilePath ? ['--settings', profilePath] : []), + ...(resumeSessionId ? ['--resume', resumeSessionId] : []), '-p', '--input-format', 'stream-json', diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 9fb7c93..5e025c4 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -558,6 +558,14 @@ export class KanbanWebviewPanel { }) return } + if (msg.type === 'pr-file-summary/generate') { + void prFiles.handleGeneratePrFileSummary(this, { + issueNumber: msg.issueNumber, + path: msg.path, + previousPath: msg.previousPath, + }) + return + } if (msg.type === 'pr-review/set') { void prFiles.handleSetPrReviewConfirmed(this, msg) return diff --git a/vscode/src/panel/handlers/prFiles.ts b/vscode/src/panel/handlers/prFiles.ts index 3aeb5d0..b143458 100644 --- a/vscode/src/panel/handlers/prFiles.ts +++ b/vscode/src/panel/handlers/prFiles.ts @@ -8,12 +8,17 @@ */ import type { KanbanWebviewPanel } from '../KanbanPanel' +import { createPatch } from 'diff' import { workspace } from 'vscode' import { getToken } from '../../auth/secrets' +import { listClaudeProfiles } from '../../cc/profiles' +import { spawnClaude } from '../../cc/spawnClaude' import { detectRepo } from '../../git/remote' import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api' import { readStateJsonComment } from '../../gitea/stateJson' +import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore' import { readConfirmed, writeConfirmed } from '../../sessions/prReviewStore' +import { makeNonce } from '../KanbanPanel' /** * 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined, @@ -106,13 +111,13 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n try { const ctx = await resolveRepoContext(panel) if (!ctx) { - panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] }) + panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [], summaries: {} }) return } const index = await resolvePrNumber(ctx, issueNumber) if (index === undefined) { - panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] }) + panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [], summaries: {} }) return } @@ -127,9 +132,12 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n index, }) const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - const confirmed = workspaceRoot - ? await readConfirmed(workspaceRoot, issueNumber, pr.headSha) - : [] + const [confirmed, summaries] = workspaceRoot + ? await Promise.all([ + readConfirmed(workspaceRoot, issueNumber, pr.headSha), + readFileSummaries(workspaceRoot, issueNumber), + ]) + : [[], {}] panel.postMessage({ type: 'pr-files/show', issueNumber, @@ -137,6 +145,7 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n mergeBase, files, confirmed, + summaries, }) } catch (err) { @@ -147,6 +156,7 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n mergeBase: '', files: [], confirmed: [], + summaries: {}, error: err instanceof Error ? err.message : String(err), }) } @@ -220,3 +230,101 @@ export async function handleSetPrReviewConfirmed( console.warn('handleSetPrReviewConfirmed 写盘失败', err) } } + +/** 生成说明的 prompt:首次带审查上下文设定,续会话只发新文件,让 deepseek 复用已有 PR 理解、省 token。 */ +function buildSummaryPrompt(prNumber: number, filePath: string, patch: string, isFirst: boolean): string { + if (isFirst) { + return `我在逐个审查 PR #${prNumber} 的改动。请用一句简短中文说明下面这个文件本次改动的目的,只输出这一句,不要解释、不要代码块。\n\n文件: ${filePath}\n\n\`\`\`diff\n${patch}\n\`\`\`` + } + return `下一个文件,同样用一句简短中文说明其改动目的,只输出这一句:\n文件: ${filePath}\n\`\`\`diff\n${patch}\n\`\`\`` +} + +/** + * 用 deepseek 给单个文件生成「一句话改动目的」。增量复用同一会话:首个文件开新会话并把 + * sessionId 记到 `.spx/pr-file-summaries.json`,后续文件 `--resume` 接上去,省去每次重新理解 + * PR 的开销。说明也落盘,重载后仍显示。 + * + * diff 取 merge_base → head 两版原文用 jsdiff 拼 unified diff 喂给模型(比塞两份全文省 token)。 + * spawnClaude 走 spawn 数组参数、不经 shell,故 patch 里的引号/换行无需转义。 + */ +export async function handleGeneratePrFileSummary( + panel: KanbanWebviewPanel, + args: { issueNumber: number, path: string, previousPath?: string }, +): Promise { + const { issueNumber, path: filePath } = args + try { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + const ctx = await resolveRepoContext(panel) + if (!ctx || !workspaceRoot) { + panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '当前工作区没有 Gitea 仓库或未配置 token' }) + return + } + + // deepseek profile 与「一键提交」同一来源;缺失则提示用户创建并清掉前端 loading。 + const profiles = await listClaudeProfiles() + const deepseek = profiles.find(p => p.name === 'deepseek-v4-pro') + if (!deepseek) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '未找到 deepseek profile,请在 /home/cruldra/Sources/cruldra-profile/claude-config/profiles/ 下创建 deepseek.json', + dismissOnTimer: 8000, + }) + panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '未找到 deepseek profile' }) + return + } + + const index = await resolvePrNumber(ctx, issueNumber) + if (index === undefined) { + panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '该工单还没有 PR' }) + return + } + + const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index }) + const mergeBase = pr.mergeBase || pr.baseSha + const oldPath = args.previousPath ?? filePath + const [oldContent, newContent] = await Promise.all([ + getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }), + getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: filePath, ref: pr.headSha }), + ]) + const patch = createPatch(filePath, oldContent, newContent, '', '') + + const prevSession = await readSummarySession(workspaceRoot, issueNumber) + let result + try { + result = await spawnClaude({ + prompt: buildSummaryPrompt(index, filePath, patch, !prevSession), + cwd: workspaceRoot, + profilePath: deepseek.path, + resumeSessionId: prevSession, + timeoutMs: 120_000, + }) + } + catch (err) { + // resume 失败(会话过期/被清理)→ 丢弃旧 id,按首次重开一个会话再试一次。 + if (!prevSession) + throw err + await writeSummarySession(workspaceRoot, issueNumber, undefined) + result = await spawnClaude({ + prompt: buildSummaryPrompt(index, filePath, patch, true), + cwd: workspaceRoot, + profilePath: deepseek.path, + timeoutMs: 120_000, + }) + } + + const summary = result.resultText.trim().replace(/\s+/g, ' ') + await writeFileSummary(workspaceRoot, issueNumber, filePath, summary) + await writeSummarySession(workspaceRoot, issueNumber, result.sessionId) + panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, summary }) + } + catch (err) { + panel.postMessage({ + type: 'pr-file-summary/show', + issueNumber, + path: filePath, + error: err instanceof Error ? err.message : String(err), + }) + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 865abf3..e4ab1a5 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -100,8 +100,9 @@ export type ExtensionToWebview | { type: 'issue/remove', issueNumber: number } | { type: 'profiles/show', data: ProfilesData } | { type: 'managed-sessions/show', data: ManagedSessionsShowData } - | { 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, 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' } @@ -174,4 +175,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[] } diff --git a/vscode/src/sessions/prFileSummaryStore.ts b/vscode/src/sessions/prFileSummaryStore.ts new file mode 100644 index 0000000..598e273 --- /dev/null +++ b/vscode/src/sessions/prFileSummaryStore.ts @@ -0,0 +1,113 @@ +/** + * Workspace 级「PR 文件改动说明 + 续会话 id」持久化。 + * + * 落盘到 `/.spx/pr-file-summaries.json`,形状 `Record`。 + * 用前缀给两类值分命名空间,避免「文件路径」与「会话 key」相撞: + * - `sum:${issueNumber}:${path}` → 该文件一句话改动说明(跨 push 保留,不随 head sha 失效)。 + * - `ses:${issueNumber}` → 该工单最近一次 deepseek 生成会话的 sessionId,供下次 `--resume`。 + * + * 文件不存在或解析失败时按空处理。 + */ + +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' + +type SummaryMap = Record + +function summaryFile(workspaceRoot: string): string { + return path.join(workspaceRoot, '.spx', 'pr-file-summaries.json') +} + +function summaryKey(issueNumber: number, filePath: string): string { + return `sum:${issueNumber}:${filePath}` +} + +function sessionKey(issueNumber: number): string { + return `ses:${issueNumber}` +} + +/** 读出整张映射;文件不存在或解析失败返回空对象。 */ +async function readMap(workspaceRoot: string): Promise { + let raw: string + try { + raw = await fsp.readFile(summaryFile(workspaceRoot), 'utf8') + } + catch { + return {} + } + try { + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + return {} + const out: SummaryMap = {} + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'string') + out[k] = v + } + return out + } + catch { + return {} + } +} + +async function writeMap(workspaceRoot: string, map: SummaryMap): Promise { + const dir = path.join(workspaceRoot, '.spx') + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(summaryFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8') +} + +/** 读某工单全部已生成的文件说明:`{ [path]: summary }`。无则空对象。 */ +export async function readFileSummaries( + workspaceRoot: string, + issueNumber: number, +): Promise> { + const map = await readMap(workspaceRoot) + const prefix = `sum:${issueNumber}:` + const out: Record = {} + for (const [k, v] of Object.entries(map)) { + if (k.startsWith(prefix)) + out[k.slice(prefix.length)] = v + } + return out +} + +/** 写某文件的说明。空串则删除该 key。 */ +export async function writeFileSummary( + workspaceRoot: string, + issueNumber: number, + filePath: string, + summary: string, +): Promise { + const map = await readMap(workspaceRoot) + const key = summaryKey(issueNumber, filePath) + if (summary.length > 0) + map[key] = summary + else + delete map[key] + await writeMap(workspaceRoot, map) +} + +/** 读某工单最近一次生成会话的 sessionId;无则 undefined。 */ +export async function readSummarySession( + workspaceRoot: string, + issueNumber: number, +): Promise { + const map = await readMap(workspaceRoot) + return map[sessionKey(issueNumber)] || undefined +} + +/** 写某工单续会话 sessionId。传 undefined / 空串则删除(下次从头开会话)。 */ +export async function writeSummarySession( + workspaceRoot: string, + issueNumber: number, + sessionId: string | undefined, +): Promise { + const map = await readMap(workspaceRoot) + const key = sessionKey(issueNumber) + if (sessionId && sessionId.length > 0) + map[key] = sessionId + else + delete map[key] + await writeMap(workspaceRoot, map) +} diff --git a/vscode/webview-ui/src/components/PrFilesPanel.tsx b/vscode/webview-ui/src/components/PrFilesPanel.tsx index 5d28ada..1311ff2 100644 --- a/vscode/webview-ui/src/components/PrFilesPanel.tsx +++ b/vscode/webview-ui/src/components/PrFilesPanel.tsx @@ -36,8 +36,11 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) { loading, confirmed, diffByPath, + summariesByPath, + generatingPath, getFileDiff, setConfirmed, + generateSummary, } = usePrFiles(issueNumber) const [selectedPath, setSelectedPath] = useState(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)} /> diff --git a/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx b/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx index 0c6b075..e171054 100644 --- a/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx +++ b/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx @@ -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.Split) const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind()) @@ -82,35 +93,71 @@ export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) { return (
- {/* 右栏标题条:文件路径 + 状态徽标 + Split/Unified 切换 */} -
- {status && ( - - {statusBadge(status)} - - )} - - {path} - -
- {([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => ( - - ))} + {statusBadge(status)} + + )} + {/* 已审查则标题置灰 */} + + {path} + + {/* 审查勾:与左树同一状态源,点击联动 */} + + {/* 一键调 deepseek 生成本文件改动说明 */} + +
+ {([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => ( + + ))} +
+ {(generating || summary) && ( +
+ {generating ? '正在生成说明…' : summary} +
+ )}
{/* 内容区 */} diff --git a/vscode/webview-ui/src/hooks/usePrFiles.ts b/vscode/webview-ui/src/hooks/usePrFiles.ts index 762faba..1ca0ff5 100644 --- a/vscode/webview-ui/src/hooks/usePrFiles.ts +++ b/vscode/webview-ui/src/hooks/usePrFiles.ts @@ -31,8 +31,13 @@ export interface UsePrFilesResult { confirmed: string[] /** key ∈ diffByPath 即「已请求/已到」;值为内容。仅 key 在但值 undefined 不会出现。 */ diffByPath: Record + /** 路径 → 已生成的一句话改动说明(deepseek 增量生成、跨 push 保留)。 */ + summariesByPath: Record + /** 正在生成说明的文件路径;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([]) const [diffByPath, setDiffByPath] = useState>({}) + const [summariesByPath, setSummariesByPath] = useState>({}) + const [generatingPath, setGeneratingPath] = useState(null) // issueNumber / headSha 装进 ref,让订阅闭包与回调始终读到当前值。 const issueRef = useRef(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, } } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 25e7f6b..eed8b7a 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -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, 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[] }