feat(vscode): 「改动」tab 重做为整 PR 聚合的主从式内联 diff(git-diff-view)

- 左树右 diff:左栏全 PR 文件树 + 统计条(共改 N · +X−Y · 已查看 M/N),右栏选中单文件内联 diff
- 用 @git-diff-view/react 只渲染当前选中文件,绕开上万行全量渲染的性能坑
- gitea 新增 listPullRequestFiles、getPullRequest 取 merge_base/head,按需回两版原文
- diff 走 merge_base→head 三点口径(与 Gitea 网页一致),getRawFile 404 兜底成全增/全删/改名
- 已查看态按 head sha 落盘、新 push 自然重置;去掉提交列表、原生 diff 与 spx-gitea 虚拟文档 provider
This commit is contained in:
2026-06-29 10:52:36 +08:00
parent ceea62ed16
commit fb505d59c5
18 changed files with 1149 additions and 1127 deletions
+55 -92
View File
@@ -216,6 +216,12 @@ export interface GiteaPullRequest {
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
*/
body: string
/** head 分支最新提交 shaPR diff 的右侧 ref。 */
headSha: string
/** base 分支 shamergeBase 取不到时的左侧 ref 兜底。 */
baseSha: string
/** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */
mergeBase: string
}
/**
@@ -242,6 +248,9 @@ export async function getPullRequest(opts: {
merged_at?: unknown
html_url?: unknown
body?: unknown
head?: { sha?: unknown }
base?: { sha?: unknown }
merge_base?: unknown
}
return {
number: typeof data.number === 'number' ? data.number : Number(data.number),
@@ -250,6 +259,9 @@ export async function getPullRequest(opts: {
merged_at: typeof data.merged_at === 'string' ? data.merged_at : null,
html_url: typeof data.html_url === 'string' ? data.html_url : '',
body: typeof data.body === 'string' ? data.body : '',
headSha: typeof data.head?.sha === 'string' ? data.head.sha : '',
baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '',
mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '',
}
}
@@ -513,113 +525,64 @@ export async function deleteWebhook(opts: {
})
}
/** PR 提交的精简视图:列表渲染只需 sha / 首行消息 / 作者 / 时间。 */
export interface PrCommitDto {
sha: string
message: string
authorName: string
date: string
}
/** 单个提交内的一个文件改动:path 给 diff 用,status 决定徽标颜色。 */
export interface GitCommitFileDto {
/** PR 改动里的一个文件:path/status 给文件树,additions/deletions 求和成统计条,previousFilename 用于改名时取左侧旧路径。 */
export interface PrFileDto {
path: string
status: string
}
/** 一个提交的详情:parentSha 用作 diff 左侧 reffiles 是改动清单。 */
export interface GitCommitDetailDto {
sha: string
parentSha: string | undefined
files: GitCommitFileDto[]
additions: number
deletions: number
previousFilename?: string
}
/**
* 列出某 PR 的提交。关掉 files/stat/verification 让响应尽量小,列表只用到
* 顶层字段。message 取首行(完整消息留给 commit 详情页,这里只做概览)。
* 列出某 PR 的全部改动文件(三点 diffmerge_base → head,与 Gitea 网页 diff 口径一致)。
* 分页拉到短页为止;改名文件带 previousFilename(旧路径,取左侧内容用)。
*/
export async function listPullRequestCommits(opts: {
export async function listPullRequestFiles(opts: {
host: string
token: string
owner: string
repo: string
index: number
limit?: number
}): Promise<PrCommitDto[]> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/commits`,
)
url.searchParams.set('page', '1')
url.searchParams.set('limit', String(opts.limit ?? 50))
url.searchParams.set('files', 'false')
url.searchParams.set('stat', 'false')
url.searchParams.set('verification', 'false')
}): Promise<PrFileDto[]> {
const out: PrFileDto[] = []
let page = 1
// eslint-disable-next-line no-constant-condition
while (true) {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/files`,
)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const data = await res.json() as Array<{
sha?: unknown
created?: unknown
commit?: { message?: unknown, author?: { name?: unknown, date?: unknown } }
author?: { login?: unknown } | null
}>
if (!Array.isArray(data))
return []
const result = data.map((c) => {
const fullMessage = typeof c.commit?.message === 'string' ? c.commit.message : ''
const message = fullMessage.split('\n', 1)[0] ?? ''
const authorName = typeof c.commit?.author?.name === 'string' ? c.commit.author.name : ''
const commitDate = typeof c.commit?.author?.date === 'string' ? c.commit.author.date : ''
const created = typeof c.created === 'string' ? c.created : ''
return {
sha: typeof c.sha === 'string' ? c.sha : '',
message,
authorName,
date: commitDate || created,
}
})
// 按提交时间升序(最早在最前)。ISO 字符串字典序即时间序;date 为空者排前面。
result.sort((a, b) => a.date.localeCompare(b.date))
return result
}
/**
* 取单个提交详情,带文件清单。parentSha(首个父提交)作为 diff 左侧 ref;
* 根提交无 parent 时为 undefined,调用方据此把左侧当空内容处理(呈现全新增)。
*/
export async function getGitCommit(opts: {
host: string
token: string
owner: string
repo: string
sha: string
}): Promise<GitCommitDetailDto> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/git/commits/${opts.sha}`,
)
url.searchParams.set('files', 'true')
url.searchParams.set('stat', 'false')
url.searchParams.set('verification', 'false')
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const data = await res.json() as {
sha?: unknown
parents?: Array<{ sha?: unknown }>
files?: Array<{ filename?: unknown, status?: unknown }>
}
const parentSha = typeof data.parents?.[0]?.sha === 'string' ? data.parents[0].sha : undefined
const files = Array.isArray(data.files)
? data.files.map(f => ({
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as Array<{
filename?: unknown
previous_filename?: unknown
status?: unknown
additions?: unknown
deletions?: unknown
}>
if (!Array.isArray(batch) || batch.length === 0)
break
for (const f of batch) {
const prev = typeof f.previous_filename === 'string' && f.previous_filename.length > 0
? f.previous_filename
: undefined
out.push({
path: typeof f.filename === 'string' ? f.filename : '',
status: typeof f.status === 'string' ? f.status : '',
}))
: []
return {
sha: typeof data.sha === 'string' ? data.sha : opts.sha,
parentSha,
files,
additions: typeof f.additions === 'number' ? f.additions : 0,
deletions: typeof f.deletions === 'number' ? f.deletions : 0,
previousFilename: prev,
})
}
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**
+7 -17
View File
@@ -26,7 +26,7 @@ import { webhookCoordinator } from '../webhook/coordinator'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
import * as issues from './handlers/issues'
import * as managedSessions from './handlers/managedSessions'
import * as prCommits from './handlers/prCommits'
import * as prFiles from './handlers/prFiles'
import * as sessions from './handlers/sessions'
import * as settings from './handlers/settings'
import * as terminals from './handlers/terminals'
@@ -546,30 +546,20 @@ export class KanbanWebviewPanel {
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
return
}
if (msg.type === 'pr-commits/get') {
void prCommits.handleGetPrCommits(this, msg.issueNumber)
if (msg.type === 'pr-files/get') {
void prFiles.handleGetPrFiles(this, msg.issueNumber)
return
}
if (msg.type === 'pr-commit-files/get') {
void prCommits.handleGetPrCommitFiles(this, msg.issueNumber, msg.sha)
return
}
if (msg.type === 'pr-commit-diff/open') {
void prCommits.handleOpenPrCommitDiff(this, {
if (msg.type === 'pr-file-diff/get') {
void prFiles.handleGetPrFileDiff(this, {
issueNumber: msg.issueNumber,
sha: msg.sha,
parentSha: msg.parentSha,
path: msg.path,
status: msg.status,
previousPath: msg.previousPath,
})
return
}
if (msg.type === 'pr-review/set') {
void prCommits.handleSetPrReviewConfirmed(this, msg)
return
}
if (msg.type === 'pr-review/set-commits') {
void prCommits.handleSetPrReviewConfirmedCommits(this, msg)
void prFiles.handleSetPrReviewConfirmed(this, msg)
return
}
}
-265
View File
@@ -1,265 +0,0 @@
/**
* 「提交」tab 的扩展侧 handler:实时从 Gitea 读 PR 提交、提交内文件,并用
* VS Code 原生 diff 比较改动前后。
*
* 数据全程实时拉取,不落任何 state JSON。文件内容通过一个
* TextDocumentContentProviderscheme `spx-gitea`)按需取 raw,使 diff 的两侧
* 各自指向一个 ref 下的文件。
*/
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { commands, Uri, window, workspace, type TextDocumentContentProvider } from 'vscode'
import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote'
import { getGitCommit, getRawFile, listPullRequestCommits } from '../../gitea/api'
import { readStateJsonComment } from '../../gitea/stateJson'
import { readConfirmed, readConfirmedCommits, writeConfirmed, writeConfirmedCommits } from '../../sessions/prReviewStore'
/** diff 文件内容走的虚拟文档 scheme。 */
const SPX_GITEA_SCHEME = 'spx-gitea'
/** provider 只需注册一次;模块级守卫避免重复注册同一 scheme 抛错。 */
let providerRegistered = false
/**
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined
* 调用方据此走空态/错误分支。
*/
async function resolveRepoContext(panel: KanbanWebviewPanel): Promise<
{ host: string, owner: string, repo: string, token: string } | undefined
> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return undefined
const remote = await detectRepo(workspaceRoot)
if (!remote)
return undefined
const token = await getToken(panel.context, remote.host)
if (!token)
return undefined
return { host: remote.host, owner: remote.owner, repo: remote.repo, token }
}
/**
* 拉取工单对应 PR 的提交列表并推给 webview。无 PR 时回空列表(前端按空态处理,
* 不算错误);缺工作区/远程/token 同样回空列表。真出错才带 error。
*/
export async function handleGetPrCommits(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
return
}
const stateObj = await readStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber,
})
const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined
if (!pr) {
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
return
}
const commits = await listPullRequestCommits({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
index: Number(pr),
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmedCommits = workspaceRoot
? await readConfirmedCommits(workspaceRoot, issueNumber)
: []
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits, confirmedCommits })
}
catch (err) {
panel.postMessage({
type: 'pr-commits/show',
issueNumber,
commits: [],
confirmedCommits: [],
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 取某提交的文件清单并推给 webview。parentSha 一并回传,供前端发起 diff 时
* 作为左侧 ref。
*/
export async function handleGetPrCommitFiles(
panel: KanbanWebviewPanel,
issueNumber: number,
sha: string,
): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-commit-files/show', issueNumber, sha, files: [], confirmed: [] })
return
}
const detail = await getGitCommit({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
sha,
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmed = workspaceRoot
? await readConfirmed(workspaceRoot, issueNumber, sha)
: []
panel.postMessage({
type: 'pr-commit-files/show',
issueNumber,
sha,
parentSha: detail.parentSha,
files: detail.files,
confirmed,
})
}
catch (err) {
panel.postMessage({
type: 'pr-commit-files/show',
issueNumber,
sha,
files: [],
confirmed: [],
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 持久化某提交里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`。
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
*/
export async function handleSetPrReviewConfirmed(
panel: KanbanWebviewPanel,
args: { issueNumber: number, sha: string, confirmed: string[] },
): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
await writeConfirmed(workspaceRoot, args.issueNumber, args.sha, args.confirmed)
}
catch (err) {
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
}
}
/**
* 持久化某工单已确认(已审阅)的提交 sha 集合到 `.spx/pr-review-confirmed.json`。
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
*/
export async function handleSetPrReviewConfirmedCommits(
panel: KanbanWebviewPanel,
args: { issueNumber: number, confirmed: string[] },
): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
await writeConfirmedCommits(workspaceRoot, args.issueNumber, args.confirmed)
}
catch (err) {
console.warn('handleSetPrReviewConfirmedCommits 写盘失败', err)
}
}
/**
* 懒注册 raw 内容 provider(只一次)。闭包捕获 panel 以便取 token——diff 两侧的
* 文档内容都经此 provider 按 uri 里的 ref 拉取。
*/
function ensureContentProvider(panel: KanbanWebviewPanel): void {
if (providerRegistered)
return
providerRegistered = true
const provider: TextDocumentContentProvider = {
async provideTextDocumentContent(uri: Uri): Promise<string> {
const params = new URLSearchParams(uri.query)
const host = params.get('host') ?? ''
const owner = params.get('owner') ?? ''
const repo = params.get('repo') ?? ''
const ref = params.get('ref') ?? ''
// uri.path 形如 `/src/foo.ts`:去掉前导斜杠并 decode 还原原始路径。
const filepath = decodeURIComponent(uri.path.replace(/^\//, ''))
if (!host || !owner || !repo)
return ''
const token = await getToken(panel.context, host)
if (!token)
return ''
return getRawFile({ host, owner, repo, token, filepath, ref })
},
}
const disposable = workspace.registerTextDocumentContentProvider(SPX_GITEA_SCHEME, provider)
panel.context.subscriptions.push(disposable)
}
/** 把改动文件的一侧(某 ref 下内容)编码成 spx-gitea 虚拟文档 uri。 */
function buildSideUri(opts: {
host: string
owner: string
repo: string
ref: string
path: string
}): Uri {
const query = new URLSearchParams({
host: opts.host,
owner: opts.owner,
repo: opts.repo,
ref: opts.ref,
}).toString()
return Uri.from({ scheme: SPX_GITEA_SCHEME, path: `/${opts.path}`, query })
}
/**
* 用 VS Code 原生 diff 比较一个文件在 parentSha → sha 之间的改动。
*
* 左侧 = parentSha 下内容,右侧 = sha 下内容。根提交无 parentSha 时左侧 ref 给
* 空串,raw 取不到(404)→ 空内容,自然呈现全新增。删除文件右侧在 sha 下 404
* → 空,呈现删除。
*/
export async function handleOpenPrCommitDiff(
panel: KanbanWebviewPanel,
args: { issueNumber: number, sha: string, parentSha?: string, path: string, status: string },
): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
void window.showWarningMessage('当前工作区没有 Gitea 仓库或未配置 token')
return
}
ensureContentProvider(panel)
const leftUri = buildSideUri({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
ref: args.parentSha ?? '',
path: args.path,
})
const rightUri = buildSideUri({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
ref: args.sha,
path: args.path,
})
const title = `${args.path} (${(args.parentSha ?? '∅').slice(0, 7)}${args.sha.slice(0, 7)})`
await commands.executeCommand('vscode.diff', leftUri, rightUri, title)
}
catch (err) {
void window.showErrorMessage(`打开 diff 失败:${err instanceof Error ? err.message : String(err)}`)
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* 「改动」tab 的扩展侧 handler:实时从 Gitea 读整个 PR 的改动文件清单,并按需取
* 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。
*
* 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json`
* (键含 head sha,新 push 自然重置)。diff 左右两侧用 merge_base → head 的原文,
* 与 Gitea 网页 diff 口径一致;getRawFile 对 404 兜底空串,故新增/删除/改名天然成立。
*/
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote'
import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api'
import { readStateJsonComment } from '../../gitea/stateJson'
import { readConfirmed, writeConfirmed } from '../../sessions/prReviewStore'
/**
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined
* 调用方据此走空态/错误分支。
*/
async function resolveRepoContext(panel: KanbanWebviewPanel): Promise<
{ host: string, owner: string, repo: string, token: string } | undefined
> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return undefined
const remote = await detectRepo(workspaceRoot)
if (!remote)
return undefined
const token = await getToken(panel.context, remote.host)
if (!token)
return undefined
return { host: remote.host, owner: remote.owner, repo: remote.repo, token }
}
/** 从工单的 state JSON 取 PR 号。无则返回 undefined(前端按空态处理,不算错误)。 */
async function resolvePrNumber(
ctx: { host: string, owner: string, repo: string, token: string },
issueNumber: number,
): Promise<number | undefined> {
const stateObj = await readStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber,
})
const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined
return pr ? Number(pr) : undefined
}
/** 由文件扩展名推断高亮语言 idhighlight.js/lowlight 口径);未知则回退扩展名本身。 */
function langFromPath(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
json: 'json',
py: 'python',
go: 'go',
rs: 'rust',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
rb: 'ruby',
php: 'php',
c: 'c',
h: 'c',
cpp: 'cpp',
cc: 'cpp',
hpp: 'cpp',
cs: 'csharp',
swift: 'swift',
md: 'markdown',
markdown: 'markdown',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
ini: 'ini',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
sql: 'sql',
css: 'css',
scss: 'scss',
less: 'less',
html: 'xml',
htm: 'xml',
xml: 'xml',
vue: 'xml',
svelte: 'xml',
}
return map[ext] ?? ext
}
/**
* 拉取工单对应 PR 的全部改动文件清单 + head/mergeBase ref + 已确认集合,推给 webview。
* 无 PR/缺上下文回空(前端按空态处理);真出错才带 error。
*/
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: [] })
return
}
const index = await resolvePrNumber(ctx, issueNumber)
if (index === undefined) {
panel.postMessage({ type: 'pr-files/show', issueNumber, headSha: '', mergeBase: '', files: [], confirmed: [] })
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,
repo: ctx.repo,
token: ctx.token,
index,
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmed = workspaceRoot
? await readConfirmed(workspaceRoot, issueNumber, pr.headSha)
: []
panel.postMessage({
type: 'pr-files/show',
issueNumber,
headSha: pr.headSha,
mergeBase,
files,
confirmed,
})
}
catch (err) {
panel.postMessage({
type: 'pr-files/show',
issueNumber,
headSha: '',
mergeBase: '',
files: [],
confirmed: [],
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 取单个文件改动前后的原文:左 = previousPath@mergeBase(无改名则 path),
* 右 = path@head。两侧原文 + 语言推断回给 webview,由 git-diff-view 内联渲染。
*/
export async function handleGetPrFileDiff(
panel: KanbanWebviewPanel,
args: { issueNumber: number, path: string, previousPath?: string },
): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' })
return
}
const index = await resolvePrNumber(ctx, args.issueNumber)
if (index === undefined) {
panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' })
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 ?? args.path
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: args.path, ref: pr.headSha }),
])
panel.postMessage({
type: 'pr-file-diff/show',
issueNumber: args.issueNumber,
path: args.path,
oldContent,
newContent,
oldLang: langFromPath(oldPath),
newLang: langFromPath(args.path),
})
}
catch (err) {
panel.postMessage({
type: 'pr-file-diff/show',
issueNumber: args.issueNumber,
path: args.path,
oldContent: '',
newContent: '',
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 持久化某 PR 里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`
* 键含 head sha。拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失
* 不该中断 UI。
*/
export async function handleSetPrReviewConfirmed(
panel: KanbanWebviewPanel,
args: { issueNumber: number, headSha: string, confirmed: string[] },
): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
await writeConfirmed(workspaceRoot, args.issueNumber, args.headSha, args.confirmed)
}
catch (err) {
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
}
}
+13 -17
View File
@@ -19,18 +19,16 @@ export interface ManagedSessionsShowData {
sessions: ManagedSessionShowItem[]
}
/** PR 提交的精简视图(webview 列表渲染用)。 */
export interface PrCommit {
sha: string
message: string
authorName: string
date: string
}
/** 提交内单个文件改动:status 取 added/modified/deleted/renamed/copied 等。 */
export interface PrCommitFile {
/**
* PR 改动里的一个文件:status 取 added/modified/deleted/renamed/copied 等,
* additions/deletions 求和成统计条,previousFilename 在改名时给出旧路径。
*/
export interface PrFile {
path: string
status: string
additions: number
deletions: number
previousFilename?: string
}
export interface ToastLink {
@@ -102,8 +100,8 @@ export type ExtensionToWebview
| { type: 'issue/remove', issueNumber: number }
| { type: 'profiles/show', data: ProfilesData }
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
| { type: 'pr-commits/show', issueNumber: number, commits: PrCommit[], confirmedCommits: string[], error?: string }
| { type: 'pr-commit-files/show', issueNumber: number, sha: string, parentSha?: string, files: PrCommitFile[], confirmed: string[], error?: string }
| { type: 'pr-files/show', issueNumber: number, headSha: string, mergeBase: string, files: PrFile[], confirmed: string[], error?: string }
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
@@ -174,8 +172,6 @@ export type WebviewToExtension
| { type: 'managed-sessions/resume', sessionId: string }
| { type: 'managed-sessions/delete', sessionId: string }
| { type: 'managed-sessions/close-tab', sessionId: string }
| { type: 'pr-commits/get', issueNumber: number }
| { type: 'pr-commit-files/get', issueNumber: number, sha: string }
| { type: 'pr-commit-diff/open', issueNumber: number, sha: string, parentSha?: string, path: string, status: string }
| { type: 'pr-review/set', issueNumber: number, sha: string, confirmed: string[] }
| { type: 'pr-review/set-commits', issueNumber: number, confirmed: string[] }
| { type: 'pr-files/get', issueNumber: number }
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
| { type: 'pr-review/set', issueNumber: number, headSha: string, confirmed: string[] }
+4 -36
View File
@@ -2,9 +2,10 @@
* Workspace 级「PR 审查已确认文件」持久化。
*
* 落盘到 `<workspace>/.spx/pr-review-confirmed.json`,形状
* `Record<string, string[]>`key = `${issueNumber}:${sha}`value = 该提交里
* 已确认(已审阅)的文件完整路径数组。目录不单独记录——目录是否确认靠「其下
* 可见文件是否全已确认」推导。文件不存在或解析失败时按空处理。
* `Record<string, string[]>`key = `${issueNumber}:${sha}`sha 取 PR 的 head
* sha,故新 push 换 key、已查看进度自然重置),value = 该 PR 里已确认(已审阅)
* 的文件完整路径数组。目录不单独记录——目录是否确认靠「其下可见文件是否全已
* 确认」推导。文件不存在或解析失败时按空处理。
*/
import { promises as fsp } from 'node:fs'
@@ -79,36 +80,3 @@ export async function writeConfirmed(
await fsp.mkdir(dir, { recursive: true })
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
}
/**
* 读取某工单已确认(已审阅)的提交 sha 数组。key = `${issueNumber}:commits`
* `commits` 非合法 sha,不与文件确认的 `${issueNumber}:${sha}` key 冲突)。
* 文件缺失、解析失败或无该 key 时返回 `[]`。
*/
export async function readConfirmedCommits(
workspaceRoot: string,
issueNumber: number,
): Promise<string[]> {
const map = await readMap(workspaceRoot)
return map[`${issueNumber}:commits`] ?? []
}
/**
* 写入某工单已确认的提交 sha 数组。空数组则删除该 key;非空则设值。父目录不存在
* 会自动创建。
*/
export async function writeConfirmedCommits(
workspaceRoot: string,
issueNumber: number,
shas: string[],
): Promise<void> {
const map = await readMap(workspaceRoot)
const key = `${issueNumber}:commits`
if (shas.length > 0)
map[key] = shas
else
delete map[key]
const dir = path.join(workspaceRoot, '.spx')
await fsp.mkdir(dir, { recursive: true })
await fsp.writeFile(confirmedFile(workspaceRoot), `${JSON.stringify(map, null, 2)}\n`, 'utf8')
}