♻️ fix(vscode): 「已查看」改为按文件变更指纹失效,PR 新 push 只摘变动文件的勾

原先按 head sha 存已查看,作者一推新提交 head sha 变了,全部勾一起清空——
哪怕只改了一个文件也把没动的勾全冲掉。改成 GitHub「Viewed」式的内容感知:
按每个文件的变更指纹 (status|additions|deletions) 存,拉文件时与当前指纹比对,
只有真改动过的文件掉勾,没动的保留;比对后顺手剪掉盘上失配/消失的条目。

Gitea 的 ChangedFile 不返回 blob sha、compare 端点也不支持任意 commit sha,
故用上述三元组作指纹(文件一改几乎必变其一),零额外请求。

- prReviewStore: 形状改为 issueNumber → {path: sig},导出 read/writeConfirmedSigs
- prFiles: handleGetPrFiles 按指纹过滤+剪枝,顺带去掉不再需要的 getPullRequest 调用
- 消息 pr-files/show 去掉无人消费的 headSha/mergeBase;pr-review/set 改发 {path: sig}
- usePrFiles: 删 headSha,setConfirmed 把路径映射成指纹再发
This commit is contained in:
2026-06-29 16:55:11 +08:00
parent bd8ba714b3
commit 35085f72f7
6 changed files with 96 additions and 75 deletions
+43 -32
View File
@@ -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<void> {
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<string, string> = {}
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<string, string> = {}
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<string, string> },
): Promise<void> {
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)
+2 -2
View File
@@ -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<string, string>, error?: string }
| { type: 'pr-files/show', issueNumber: number, 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 }
@@ -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<string, string> }
+34 -27
View File
@@ -1,27 +1,31 @@
/**
* Workspace 级「PR 审查已确认文件」持久化。
* Workspace 级「PR 审查已查看文件」持久化。
*
* 落盘到 `<workspace>/.spx/pr-review-confirmed.json`,形状
* `Record<string, string[]>`key = `${issueNumber}:${sha}`sha 取 PR 的 head
* sha,故新 push 换 key、已查看进度自然重置),value = 该 PR 里已确认(已审阅)
* 的文件完整路径数组。目录不单独记录——目录是否确认靠「其下可见文件是否全已
* 确认」推导。文件不存在或解析失败时按空处理。
* `Record<string, Record<string, string>>`:外层 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<string, string[]>
/** 外层 key = issueNumber 字符串;内层 = 文件路径 → 变更指纹。 */
type ConfirmedMap = Record<string, Record<string, string>>
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<ConfirmedMap> {
const file = confirmedFile(workspaceRoot)
let raw: string
@@ -37,8 +41,14 @@ async function readMap(workspaceRoot: string): Promise<ConfirmedMap> {
return {}
const out: ConfirmedMap = {}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
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<string, string> = {}
for (const [p, sig] of Object.entries(value as Record<string, unknown>)) {
if (typeof sig === 'string')
sigs[p] = sig
}
out[key] = sigs
}
return out
}
@@ -48,32 +58,29 @@ async function readMap(workspaceRoot: string): Promise<ConfirmedMap> {
}
/**
* 读取某 (issueNumber, sha) 下已确认的文件路径数组。文件缺失、解析失败或无该
* key 时返回 `[]`。
* 读取某工单已查看文件的「路径 → 指纹」映射。文件缺失、解析失败或无该工单时返回 `{}`。
*/
export async function readConfirmed(
export async function readConfirmedSigs(
workspaceRoot: string,
issueNumber: number,
sha: string,
): Promise<string[]> {
): Promise<Record<string, string>> {
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<string, string>,
): Promise<void> {
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')