diff --git a/vscode/src/panel/handlers/prFiles.ts b/vscode/src/panel/handlers/prFiles.ts index ddd5319..c321dbf 100644 --- a/vscode/src/panel/handlers/prFiles.ts +++ b/vscode/src/panel/handlers/prFiles.ts @@ -3,8 +3,9 @@ * 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。 * * 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json` - * (键含 head sha,新 push 自然重置)。diff 左右两侧用 merge_base → head 的原文, - * 与 Gitea 网页 diff 口径一致;getRawFile 对 404 兜底空串,故新增/删除/改名天然成立。 + * (按文件变更指纹失效:作者推新提交后,仅真改动过的文件掉勾,没动的保留)。diff + * 左右两侧用 merge_base → head 的原文,与 Gitea 网页 diff 口径一致;getRawFile 对 + * 404 兜底空串,故新增/删除/改名天然成立。 */ import type { KanbanWebviewPanel } from '../KanbanPanel' @@ -17,7 +18,7 @@ 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 { readConfirmedSigs, writeConfirmedSigs } from '../../sessions/prReviewStore' import { makeNonce } from '../KanbanPanel' /** @@ -103,27 +104,33 @@ function langFromPath(path: string): string { return map[ext] ?? ext } +/** 文件「变更指纹」:内容一改,status/additions/deletions 几乎必变其一;用于判定已查看是否仍有效。 */ +function fileSig(f: { status: string, additions: number, deletions: number }): string { + return `${f.status}|${f.additions}|${f.deletions}` +} + /** - * 拉取工单对应 PR 的全部改动文件清单 + head/mergeBase ref + 已确认集合,推给 webview。 + * 拉取工单对应 PR 的全部改动文件清单 + 已查看集合 + 已生成说明,推给 webview。 * 无 PR/缺上下文回空(前端按空态处理);真出错才带 error。 + * + * 已查看按「变更指纹」判定:取每个文件当前的 status|additions|deletions,与落盘记录的 + * 指纹比对,仅相符的文件仍算已查看——作者推新提交后只有真改动过的文件掉勾。比对后顺手把 + * 已失配/已消失的条目从盘上剪掉。 */ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { const ctx = await resolveRepoContext(panel) if (!ctx) { - panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [], summaries: {} }) + panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return } const index = await resolvePrNumber(ctx, issueNumber) if (index === undefined) { - panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [], summaries: {} }) + panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return } - const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index }) - // 三点 diff 的左侧 ref:优先 merge_base,缺失则退回 base sha。 - const mergeBase = pr.mergeBase || pr.baseSha const files = await listPullRequestFiles({ host: ctx.host, owner: ctx.owner, @@ -131,29 +138,34 @@ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: n token: ctx.token, index, }) + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - const [confirmed, summaries] = workspaceRoot - ? await Promise.all([ - readConfirmed(workspaceRoot, issueNumber, pr.headSha), - readFileSummaries(workspaceRoot, issueNumber), - ]) - : [[], {}] - panel.postMessage({ - type: 'pr-files/show', - issueNumber, - headSha: pr.headSha, - mergeBase, - files, - confirmed, - summaries, - }) + let confirmed: string[] = [] + let summaries: Record = {} + if (workspaceRoot) { + const [stored, sums] = await Promise.all([ + readConfirmedSigs(workspaceRoot, issueNumber), + readFileSummaries(workspaceRoot, issueNumber), + ]) + summaries = sums + // 当前每文件的变更指纹;仅指纹相符的已查看条目仍有效。 + const cur = new Map(files.map(f => [f.path, fileSig(f)])) + confirmed = Object.keys(stored).filter(p => cur.get(p) === stored[p]) + // 有失配/消失才写盘,剪掉陈旧条目,避免无谓磁盘写入。 + if (confirmed.length !== Object.keys(stored).length) { + const pruned: Record = {} + for (const p of confirmed) + pruned[p] = stored[p] + await writeConfirmedSigs(workspaceRoot, issueNumber, pruned) + } + } + + panel.postMessage({ type: 'pr-files/show', issueNumber, files, confirmed, summaries }) } catch (err) { panel.postMessage({ type: 'pr-files/show', issueNumber, - headSha: '', - mergeBase: '', files: [], confirmed: [], summaries: {}, @@ -212,19 +224,18 @@ export async function handleGetPrFileDiff( } /** - * 持久化某 PR 里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`, - * 键含 head sha。拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失 - * 不该中断 UI。 + * 持久化某 PR 的「已查看文件 → 变更指纹」映射到 `.spx/pr-review-confirmed.json`。 + * 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。 */ export async function handleSetPrReviewConfirmed( - panel: KanbanWebviewPanel, - args: { issueNumber: number, headSha: string, confirmed: string[] }, + _panel: KanbanWebviewPanel, + args: { issueNumber: number, confirmed: Record }, ): Promise { try { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return - await writeConfirmed(workspaceRoot, args.issueNumber, args.headSha, args.confirmed) + await writeConfirmedSigs(workspaceRoot, args.issueNumber, args.confirmed) } catch (err) { console.warn('handleSetPrReviewConfirmed 写盘失败', err) diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index e4ab1a5..a914448 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -100,7 +100,7 @@ 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[], summaries: Record, error?: string } + | { type: 'pr-files/show', issueNumber: number, 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 } @@ -176,4 +176,4 @@ export type WebviewToExtension | { 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[] } + | { type: 'pr-review/set', issueNumber: number, confirmed: Record } diff --git a/vscode/src/sessions/prReviewStore.ts b/vscode/src/sessions/prReviewStore.ts index 7c2bec8..972d79b 100644 --- a/vscode/src/sessions/prReviewStore.ts +++ b/vscode/src/sessions/prReviewStore.ts @@ -1,27 +1,31 @@ /** - * Workspace 级「PR 审查已确认文件」持久化。 + * Workspace 级「PR 审查已查看文件」持久化。 * * 落盘到 `/.spx/pr-review-confirmed.json`,形状 - * `Record`:key = `${issueNumber}:${sha}`(sha 取 PR 的 head - * sha,故新 push 换 key、已查看进度自然重置),value = 该 PR 里已确认(已审阅) - * 的文件完整路径数组。目录不单独记录——目录是否确认靠「其下可见文件是否全已 - * 确认」推导。文件不存在或解析失败时按空处理。 + * `Record>`:外层 key = `${issueNumber}`,内层 + * `{ [filePath]: sig }`,sig 是该文件被标记已查看时记下的「变更指纹」。 + * + * 为什么用指纹而非 blob sha:要做到 GitHub 式「文件没变就一直算已查看,只有它在新 + * push 里真改动了才重新标未查看」,需要一个能判定文件是否变过的标识。但 Gitea 的 + * ChangedFile 不返回每文件 blob sha、compare 端点也不支持任意 commit sha 比较,拿 + * 不到现成的内容标识。故退而用 (status|additions|deletions) 作指纹——文件一旦改动, + * 这三者几乎必然变其一,据此判断是否需要重看,够用且零额外请求。 + * + * 目录不单独记录——目录是否已查看靠「其下可见文件是否全已查看」推导。文件不存在或 + * 解析失败时按空处理。 */ import { promises as fsp } from 'node:fs' import * as path from 'node:path' -type ConfirmedMap = Record +/** 外层 key = issueNumber 字符串;内层 = 文件路径 → 变更指纹。 */ +type ConfirmedMap = Record> function confirmedFile(workspaceRoot: string): string { return path.join(workspaceRoot, '.spx', 'pr-review-confirmed.json') } -function makeKey(issueNumber: number, sha: string): string { - return `${issueNumber}:${sha}` -} - -/** 读出整个映射;文件不存在或解析失败返回空对象。 */ +/** 读出整个映射;文件不存在、解析失败或形状不符返回空对象。 */ async function readMap(workspaceRoot: string): Promise { const file = confirmedFile(workspaceRoot) let raw: string @@ -37,8 +41,14 @@ async function readMap(workspaceRoot: string): Promise { return {} const out: ConfirmedMap = {} for (const [key, value] of Object.entries(parsed as Record)) { - if (Array.isArray(value)) - out[key] = value.filter((p): p is string => typeof p === 'string') + if (!value || typeof value !== 'object' || Array.isArray(value)) + continue + const sigs: Record = {} + for (const [p, sig] of Object.entries(value as Record)) { + if (typeof sig === 'string') + sigs[p] = sig + } + out[key] = sigs } return out } @@ -48,32 +58,29 @@ async function readMap(workspaceRoot: string): Promise { } /** - * 读取某 (issueNumber, sha) 下已确认的文件路径数组。文件缺失、解析失败或无该 - * key 时返回 `[]`。 + * 读取某工单已查看文件的「路径 → 指纹」映射。文件缺失、解析失败或无该工单时返回 `{}`。 */ -export async function readConfirmed( +export async function readConfirmedSigs( workspaceRoot: string, issueNumber: number, - sha: string, -): Promise { +): Promise> { const map = await readMap(workspaceRoot) - return map[makeKey(issueNumber, sha)] ?? [] + return map[String(issueNumber)] ?? {} } /** - * 写入某 (issueNumber, sha) 下已确认的文件路径数组。空数组则删除该 key;非空则 - * 设值。父目录不存在会自动创建。 + * 写入某工单已查看文件的「路径 → 指纹」映射。空映射则删除该工单 key;非空则设值。 + * 父目录不存在会自动创建。 */ -export async function writeConfirmed( +export async function writeConfirmedSigs( workspaceRoot: string, issueNumber: number, - sha: string, - paths: string[], + sigs: Record, ): Promise { const map = await readMap(workspaceRoot) - const key = makeKey(issueNumber, sha) - if (paths.length > 0) - map[key] = paths + const key = String(issueNumber) + if (Object.keys(sigs).length > 0) + map[key] = sigs else delete map[key] const dir = path.join(workspaceRoot, '.spx') diff --git a/vscode/webview-ui/src/components/PrFilesPanel.tsx b/vscode/webview-ui/src/components/PrFilesPanel.tsx index 1311ff2..144e672 100644 --- a/vscode/webview-ui/src/components/PrFilesPanel.tsx +++ b/vscode/webview-ui/src/components/PrFilesPanel.tsx @@ -6,7 +6,7 @@ * - 右栏:选中文件的内联 diff(git-diff-view,只渲染当前一个文件)。 * * 自带 usePrFiles hook,直接经 lib/vscode 收发消息,不依赖上层回调。diff 左右两侧用 - * merge_base → head 的原文(与 Gitea 网页 diff 口径一致),已查看态按 head sha 落盘。 + * merge_base → head 的原文(与 Gitea 网页 diff 口径一致),已查看态按文件变更指纹落盘。 */ import type { Issue } from '../types' diff --git a/vscode/webview-ui/src/hooks/usePrFiles.ts b/vscode/webview-ui/src/hooks/usePrFiles.ts index 1ca0ff5..c1e3610 100644 --- a/vscode/webview-ui/src/hooks/usePrFiles.ts +++ b/vscode/webview-ui/src/hooks/usePrFiles.ts @@ -7,7 +7,7 @@ * - 订阅 `pr-files/show` / `pr-file-diff/show`,只认 issueNumber 匹配的消息 * (扩展侧可能并发多工单的回包)。 * - diffByPath 缓存:同一文件点开第二次不再发请求。 - * - 已确认(已查看)按 head sha 落盘,故 setConfirmed 要带当前 headSha。 + * - 已查看按「文件变更指纹」落盘(status|additions|deletions),setConfirmed 把路径映射成指纹再发。 */ import type { PrFile } from '../lib/messages' @@ -25,7 +25,6 @@ export interface FileDiff { export interface UsePrFilesResult { files: PrFile[] - headSha: string filesError: string | undefined loading: boolean confirmed: string[] @@ -42,7 +41,6 @@ export interface UsePrFilesResult { export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { const [files, setFiles] = useState([]) - const [headSha, setHeadSha] = useState('') const [filesError, setFilesError] = useState(undefined) const [loading, setLoading] = useState(false) const [confirmed, setConfirmedState] = useState([]) @@ -50,11 +48,11 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { const [summariesByPath, setSummariesByPath] = useState>({}) const [generatingPath, setGeneratingPath] = useState(null) - // issueNumber / headSha 装进 ref,让订阅闭包与回调始终读到当前值。 + // issueNumber / files 装进 ref,让订阅闭包与回调始终读到当前值。 const issueRef = useRef(issueNumber) issueRef.current = issueNumber - const headShaRef = useRef('') - headShaRef.current = headSha + const filesRef = useRef([]) + filesRef.current = files // 已发出 diff 请求的 path 集合:防同一文件并发重复请求(缓存到达前的窗口)。 const requestedRef = useRef>(new Set()) @@ -65,7 +63,6 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { if (msg.issueNumber !== issueRef.current) return setFiles(msg.files) - setHeadSha(msg.headSha) setConfirmedState(msg.confirmed ?? []) setSummariesByPath(msg.summaries ?? {}) setFilesError(msg.error) @@ -102,7 +99,6 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { // 切工单:清空缓存与已请求集合,重新拉文件清单。无工单时只清空。 useEffect(() => { setFiles([]) - setHeadSha('') setFilesError(undefined) setConfirmedState([]) setDiffByPath({}) @@ -127,13 +123,21 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { postMessage({ type: 'pr-file-diff/get', issueNumber: num, path, previousPath }) }, []) - // 乐观更新本地确认态并落盘(键含当前 headSha);下次拉文件回包会以持久化值校正。 + // 乐观更新本地确认态并落盘;把已查看路径映射成「路径 → 变更指纹」(公式须与扩展侧一致), + // 下次拉文件回包会按指纹比对校正(文件变过则自动掉勾)。 const setConfirmed = useCallback((paths: string[]): void => { const num = issueRef.current if (num === undefined) return setConfirmedState(paths) - postMessage({ type: 'pr-review/set', issueNumber: num, headSha: headShaRef.current, confirmed: paths }) + const sigByPath = new Map(filesRef.current.map(f => [f.path, `${f.status}|${f.additions}|${f.deletions}`])) + const confirmed: Record = {} + for (const p of paths) { + const sig = sigByPath.get(p) + if (sig !== undefined) + confirmed[p] = sig + } + postMessage({ type: 'pr-review/set', issueNumber: num, confirmed }) }, []) // 触发 deepseek 增量生成该文件的改动说明;置 loading,结果经 pr-file-summary/show 回来。 @@ -147,7 +151,6 @@ export function usePrFiles(issueNumber: number | undefined): UsePrFilesResult { return { files, - headSha, filesError, loading, confirmed, diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index eed8b7a..03790c8 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -115,7 +115,7 @@ 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[], summaries: Record, error?: string } + | { type: 'pr-files/show', issueNumber: number, 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 } @@ -191,4 +191,4 @@ export type WebviewToExtension | { 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[] } + | { type: 'pr-review/set', issueNumber: number, confirmed: Record }