From 2a5806c6b5c0049ef79f90affbc6a77e1af0f530 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 08:35:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E6=8C=89=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E7=AE=97=E4=BB=A3=E7=A0=81=E5=B7=AE=E5=BC=82?= =?UTF-8?q?=EF=BC=88=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E6=9B=B4=E6=94=B9?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E4=B8=8A=E4=B8=80=E6=AC=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E3=80=81=E5=AE=A1=E6=9F=A5=E5=AF=B9=E6=AF=94=20PR=20=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=88=86=E6=94=AF=EF=BC=9B=E5=85=84=E5=BC=9F=E5=9D=97?= =?UTF-8?q?=E6=8C=89=E5=90=8D=E5=AD=97=20/=20=E7=9B=B8=E4=BC=BC=E5=BA=A6?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=EF=BC=8C=E5=8D=A1=E7=89=87=E5=86=85=E6=8C=89?= =?UTF-8?q?=E8=A1=8C=20/=20=E5=80=BC=20/=20=E8=AF=8D=E6=AF=94=E5=AF=B9?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E7=AE=97=E5=A5=BD=E5=90=8E=E8=A1=A5=E5=8F=91?= =?UTF-8?q?=E7=BB=99=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/diff/blockDiff.test.ts | 234 +++++++++++++ vscode/src/codeReader/diff/blockDiff.ts | 235 +++++++++++++ vscode/src/codeReader/diff/diffBase.ts | 14 + .../src/codeReader/diff/previewDiff.test.ts | 113 +++++++ vscode/src/codeReader/diff/previewDiff.ts | 138 ++++++++ vscode/src/codeReader/diff/viewDiff.test.ts | 187 +++++++++++ vscode/src/codeReader/diff/viewDiff.ts | 311 ++++++++++++++++++ vscode/src/codeReader/editor/messages.ts | 4 + vscode/src/codeReader/editor/previewEditor.ts | 56 +++- vscode/src/codeReader/explorer/treeFilters.ts | 28 +- vscode/src/codeReader/index.ts | 4 +- .../codeReader/review/changedFiles.test.ts | 17 +- vscode/src/codeReader/review/changedFiles.ts | 9 + vscode/src/codeReader/review/reviewMode.ts | 55 +++- .../src/codeReader/review/reviewRules.test.ts | 11 +- vscode/src/codeReader/review/reviewRules.ts | 10 + .../uncommitted/uncommittedFiles.test.ts | 17 +- .../uncommitted/uncommittedFiles.ts | 15 + .../codeReader/uncommitted/uncommittedMode.ts | 37 ++- vscode/src/git/fileAt.test.ts | 39 +++ vscode/src/git/fileAt.ts | 12 + vscode/src/git/gitApi.ts | 7 + vscode/src/gitea/api.ts | 5 +- 23 files changed, 1527 insertions(+), 31 deletions(-) create mode 100644 vscode/src/codeReader/diff/blockDiff.test.ts create mode 100644 vscode/src/codeReader/diff/blockDiff.ts create mode 100644 vscode/src/codeReader/diff/diffBase.ts create mode 100644 vscode/src/codeReader/diff/previewDiff.test.ts create mode 100644 vscode/src/codeReader/diff/previewDiff.ts create mode 100644 vscode/src/codeReader/diff/viewDiff.test.ts create mode 100644 vscode/src/codeReader/diff/viewDiff.ts create mode 100644 vscode/src/git/fileAt.test.ts create mode 100644 vscode/src/git/fileAt.ts diff --git a/vscode/src/codeReader/diff/blockDiff.test.ts b/vscode/src/codeReader/diff/blockDiff.test.ts new file mode 100644 index 0000000..b3a2396 --- /dev/null +++ b/vscode/src/codeReader/diff/blockDiff.test.ts @@ -0,0 +1,234 @@ +import type { BlockView } from '../blockView' +import type { SourcedBlocks } from './blockDiff' +import { describe, expect, it } from 'vitest' +import { readBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { runtime, WASM_DIR } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { alignSiblings, diffBlockTrees, similarity } from './blockDiff' + +const py = createPythonAdapter(WASM_DIR) + +async function python(source: string): Promise { + const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source) + return { blocks, source } +} + +/** 手写的一块:只填比对用得到的几样。 */ +function view(anchor: string, kind: string, code: string, extra: Partial = {}): BlockView { + return { + id: anchor, + anchor, + kind, + startLine: 0, + endLine: 0, + headerEndLine: 0, + code, + codeTokens: [], + attributes: { kind }, + children: [], + translation: [{ text: code, role: 'plain' }], + ...extra, + } as BlockView +} + +/** 名字写在源码开头:defines 指向它。 */ +function named(anchor: string, kind: string, name: string, code: string, source: { text: string }): BlockView { + const startIndex = source.text.length + source.text += name + return view(anchor, kind, code, { defines: { startIndex, endIndex: startIndex + name.length } }) +} + +const RELEASE_LUA = `_RELEASE_LUA = """ +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +else + return 0 +end +"""` + +const BEFORE = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再删.""" + +from __future__ import annotations + +import uuid +from typing import Any + +${RELEASE_LUA} + + +async def try_acquire(redis: Any, key: str, ttl_sec: int) -> bool: + """抢锁; 抢到返回持有者 token, 已被占用返回 None.""" + token = uuid.uuid4().hex + ok = await redis.set(key, token, nx=True, ex=ttl_sec) + return token if ok else None + + +async def _is_owner(redis: Any, key: str, token: str) -> bool: + """锁仍归 token 时返回真.""" + return await redis.get(key) == token + + +async def release(redis: Any, key: str, token: str) -> bool: + """只在锁仍归 token 时删掉; 返回是否真的删了.""" + result = await redis.eval(_RELEASE_LUA, 1, key) + return int(result) == 1 +` + +const AFTER = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再续期 / 再删.""" + +from __future__ import annotations + +import uuid +from typing import Any + +${RELEASE_LUA} + +_RENEW_LUA = """ +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('EXPIRE', KEYS[1], ARGV[2]) +else + return 0 +end +""" + + +async def try_acquire(redis: Any, key: str, ttl_sec: int, token_prefix: str = "") -> str | None: + """抢锁; 抢到返回持有者 token, 已被占用返回 None.""" + token = f"{token_prefix}{uuid.uuid4().hex}" + ok = await redis.set(key, token, nx=True, ex=ttl_sec) + return token if ok else None + + +async def renew(redis: Any, key: str, token: str, ttl_sec: int) -> bool: + """只在锁仍归 token 时把 TTL 续回 ttl_sec; 返回是否续上.""" + result = await redis.eval(_RENEW_LUA, 1, key, token, str(ttl_sec)) + return int(result) == 1 + + +async def release(redis: Any, key: str, token: str) -> bool: + """只在锁仍归 token 时删掉; 返回是否真的删了.""" + result = await redis.eval(_RELEASE_LUA, 1, key, token) + return int(result) == 1 +` + +describe('similarity', () => { + it('一样是 1,毫不相干是 0,改了一个词在中间', () => { + expect(similarity('a = 1', 'a = 1')).toBe(1) + expect(similarity('foo(bar)', 'x')).toBe(0) + const score = similarity('print(a, b)', 'print(a, c)') + expect(score).toBeGreaterThan(0.5) + expect(score).toBeLessThan(1) + }) +}) + +describe('alignSiblings', () => { + it('有名字的按种类 + 名字配,挪了位置也配得上;改名算一删一增', () => { + const source = { text: '' } + const before = [named('f', 'function', 'f', 'def f(): pass', source), named('g', 'function', 'g', 'def g(): pass', source)] + const after = [named('g2', 'function', 'g', 'def g(): return 1', source), named('h', 'function', 'h', 'def f(): pass', source)] + const { toOld, toNew } = alignSiblings(before, source.text, after, source.text) + expect(toOld).toEqual([1, undefined]) + expect(toNew).toEqual([undefined, 0]) + }) + + it('同名的照先后配', () => { + const source = { text: '' } + const before = [named('a', 'function', 'f', 'one', source), named('b', 'function', 'f', 'two', source)] + const after = [named('c', 'function', 'f', 'one', source), named('d', 'function', 'f', 'two!', source)] + expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([0, 1]) + }) + + it('种类不同的同名块不配', () => { + const source = { text: '' } + const before = [named('a', 'const', 'x', 'const x = 1', source)] + const after = [named('b', 'variable', 'x', 'let x = 1', source)] + expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined]) + }) + + it('没名字的:源码没变的按最长公共子序列配,中间的按相似度配成修改,不像的算一删一增', () => { + const before = [view('1', 'statement', 'print(a)'), view('2', 'statement', 'call(alpha, beta, gamma)'), view('3', 'statement', 'print(b)')] + const after = [ + view('1', 'statement', 'print(a)'), + view('x', 'statement', 'totally_new()'), + view('2', 'statement', 'call(alpha, beta, delta)'), + view('3', 'statement', 'print(b)'), + ] + expect(alignSiblings(before, '', after, '').toOld).toEqual([0, undefined, 1, 2]) + }) + + it('没名字的块种类不同就不按相似度配', () => { + const before = [view('1', 'statement', 'call(alpha, beta)')] + const after = [view('2', 'return', 'call(alpha, beta, gamma)')] + expect(alignSiblings(before, '', after, '').toOld).toEqual([undefined]) + }) + + it('有名字的块不按相似度配:改名算一删一增', () => { + const source = { text: '' } + const before = [named('a', 'function', 'old_name', 'def old_name(x): return x + 1', source)] + const after = [named('b', 'function', 'new_name', 'def new_name(x): return x + 1', source)] + expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined]) + }) +}) + +describe('diffBlockTrees', () => { + it('原型的 mutex.py:新增 2 · 修改 3 · 删除 1,只数最外层', async () => { + const diff = diffBlockTrees(await python(BEFORE), await python(AFTER)) + expect(diff.stats).toEqual({ add: 2, mod: 3, del: 1 }) + }) + + it('新增的块连同子孙都算新增,只有最外面那块挂小标', async () => { + const after = await python(AFTER) + const diff = diffBlockTrees(await python(BEFORE), after) + const renew = after.blocks.find(block => block.anchor === 'function renew')! + expect(diff.blocks[renew.anchor]).toEqual({ change: 'add', outermost: true }) + for (const child of renew.children) + expect(diff.blocks[child.anchor]).toEqual({ change: 'add' }) + }) + + it('修改的块:自己的内容改了带标记,子块照样比;只有子块改了的父块不算自己改了', async () => { + const after = await python(AFTER) + const diff = diffBlockTrees(await python(BEFORE), after) + const tryAcquire = after.blocks.find(block => block.anchor === 'function try_acquire')! + expect(diff.blocks[tryAcquire.anchor]).toMatchObject({ change: 'mod', own: true }) + expect(diff.blocks[tryAcquire.anchor].marks?.rows).toBeDefined() + const [token, ok, back] = tryAcquire.children + expect(diff.blocks[token.anchor]).toMatchObject({ change: 'mod', own: true }) + expect(diff.blocks[ok.anchor]).toBeUndefined() + expect(diff.blocks[back.anchor]).toBeUndefined() + + const release = after.blocks.find(block => block.anchor === 'function release')! + expect(diff.blocks[release.anchor]).toEqual({ change: 'mod' }) + expect(diff.blocks[release.children[0].anchor]).toMatchObject({ change: 'mod', own: true }) + }) + + it('删掉的块放回原来的位置:前面配上的兄弟之后,再越过紧跟着的新增块;只带它自己的内容', async () => { + const after = await python(AFTER) + const diff = diffBlockTrees(await python(BEFORE), after) + expect(diff.removed).toHaveLength(1) + const [removed] = diff.removed + expect(removed).toMatchObject({ parent: '', after: 'function renew' }) + expect(removed.block.anchor).toBe('function _is_owner') + expect(removed.block.children).toEqual([]) + expect(removed.block.defines).toBeUndefined() + const renew = after.blocks.find(block => block.anchor === 'function renew')! + expect(removed.line).toBe(renew.endLine + 1) + const names = JSON.stringify(removed.block) + expect(names).not.toContain('"offset"') + }) + + it('没变的文件没有改动', async () => { + const before = await python(BEFORE) + expect(diffBlockTrees(before, await python(BEFORE))).toEqual({ blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } }) + }) + + it('子块里删掉的块挂在父块下面,排在最前面时没有 after', async () => { + const before = await python('def f():\n a = 1\n b = 2\n') + const after = await python('def f():\n b = 2\n') + const diff = diffBlockTrees(before, after) + expect(diff.stats).toEqual({ add: 0, mod: 1, del: 0 }) + expect(diff.removed).toHaveLength(1) + expect(diff.removed[0]).toMatchObject({ parent: 'function f', line: 1 }) + expect(diff.removed[0].after).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/diff/blockDiff.ts b/vscode/src/codeReader/diff/blockDiff.ts new file mode 100644 index 0000000..7592695 --- /dev/null +++ b/vscode/src/codeReader/diff/blockDiff.ts @@ -0,0 +1,235 @@ +import type { BlockView } from '../blockView' +import type { BlockMarks } from './viewDiff' +import { diffArrays, diffWordsWithSpace } from 'diff' +import { blockMarks, removedView } from './viewDiff' + +/** + * 一份文件改之前、改之后的两棵块树比出哪些块新增、修改、删除。同一父块下的兄弟块对齐: + * 有名字的块按「种类 + 名字」配对,没名字的按位置和内容相似度配对(最长公共子序列找出没变的,夹在中间的按相似度配), + * 配不上的旧有新无算删除、旧无新有算新增;改名算一删一增。配上的块整段源码一样算没变,否则算修改、接着比子块。 + * 这里不碰 VS Code。 + */ + +/** 块的改动:新增、修改;删掉的块另列(RemovedBlock)。 */ +export type BlockChange = 'add' | 'mod' + +export interface ChangedBlock { + change: BlockChange + /** 新增的最外面那块(挂「新增」小标);它的子孙跟着算新增、不挂 */ + outermost?: true + /** 修改的块自己(不算子块)有变:卡片里的内容变了,或源码变了、子块却都没变 */ + own?: true + /** 修改的块自己的内容改了哪些(见 blockMarks) */ + marks?: BlockMarks +} + +/** + * 删掉的一块,放回它原来的位置:旧版本里它前面最近一个配上的兄弟所配的新块后面, + * 再越过紧跟在那块后面的新增块(新增的在前、删掉的在后,都夹在原来前后两个邻居中间)。 + */ +export interface RemovedBlock { + /** 在哪一层:父块的记号(BlockView.anchor),顶层是 '' */ + parent: string + /** 排在这一层哪块后面(新块的记号);排在最前面时没有 */ + after?: string + /** 旧版本里的这块:只带它自己的内容,词不带源码下标(见 removedView) */ + block: BlockView + /** 它在新文件里的位置(0 起的行号):总览条上的刻度画在这 */ + line: number +} + +/** 「新增 N · 修改 N · 删除 N」:只数最外层带标记的块,新增块的子块、修改块里改动的子块不再数。 */ +export interface DiffStats { add: number, mod: number, del: number } + +export interface TreeDiff { + /** 按新块的记号;没变的块不写 */ + blocks: Record + removed: RemovedBlock[] + stats: DiffStats +} + +/** 一份源码的块树和源码本身(块的名字按 defines 从源码里取)。 */ +export interface SourcedBlocks { + blocks: readonly BlockView[] + source: string +} + +/** 没名字的两块内容相似度(0~1)到这么多才配成修改,否则算一删一增。 */ +const SIMILAR = 0.5 +/** 没名字的块往后最多找这么多块配相似的,改动很大的文件不至于两两比个遍。 */ +const SIMILAR_WINDOW = 40 + +/** 块自己的名字:defines 那一段源码(函数名、类名、只声明一个变量时的变量名、规则卡片讲的名字);没名字的块没有。 */ +function nameOf(view: BlockView, source: string): string | undefined { + return view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) +} + +/** + * 两段源码有多像(0~1):2 × 没变的字数 ÷ 两边字数之和,按词比(中文按字、标点各算一个)。 + * 一边比另一边长三倍以上时没变的字不可能过半,直接当不像。 + */ +export function similarity(a: string, b: string): number { + if (a === b) + return 1 + if (Math.max(a.length, b.length) > 3 * Math.min(a.length, b.length)) + return 0 + const kept = diffWordsWithSpace(a, b).filter(change => !change.added && !change.removed).reduce((sum, change) => sum + change.value.length, 0) + return 2 * kept / (a.length + b.length) +} + +/** 同一父块下的兄弟块怎么对上:新的第几块配旧的第几块(没配上是 undefined),反过来也记一份。 */ +export interface Alignment { + toOld: (number | undefined)[] + toNew: (number | undefined)[] +} + +/** + * 同一父块下的兄弟块对齐: + * ① 有名字的按「种类 + 名字」配,同名的照先后,挪了位置也配得上; + * ② 以此为锚,最长公共子序列找出种类、源码都没变的没名字的块; + * ③ 夹在相邻两个锚中间、还没配上的没名字的块,照先后找种类相同、内容最像且相似度到 SIMILAR 的配上(算修改)。 + * 有名字却没配上的(改了名、删了、新加的)不按相似度配:改名算一删一增。 + */ +export function alignSiblings(before: readonly BlockView[], beforeSource: string, after: readonly BlockView[], afterSource: string): Alignment { + const toOld: (number | undefined)[] = after.map(() => undefined) + const toNew: (number | undefined)[] = before.map(() => undefined) + const pair = (old: number, now: number): void => { + toOld[now] = old + toNew[old] = now + } + const oldNames = before.map(view => nameOf(view, beforeSource)) + const newNames = after.map(view => nameOf(view, afterSource)) + const waiting = new Map() + before.forEach((view, old) => { + const name = oldNames[old] + if (name !== undefined) + waiting.set(`${view.kind}\u0000${name}`, [...waiting.get(`${view.kind}\u0000${name}`) ?? [], old]) + }) + after.forEach((view, now) => { + const name = newNames[now] + const old = name === undefined ? undefined : waiting.get(`${view.kind}\u0000${name}`)?.shift() + if (old !== undefined) + pair(old, now) + }) + + const unnamedSame = (old: number, now: number): boolean => oldNames[old] === undefined && newNames[now] === undefined + && before[old].kind === after[now].kind && before[old].code === after[now].code + const changes = diffArrays(before.map((_, i) => i), after.map((_, i) => i), { comparator: (old, now) => toNew[old] === now || unnamedSame(old, now) }) + const anchors: [number, number][] = [] + let old = 0 + let now = 0 + for (const change of changes) { + if (!change.added && !change.removed) { + for (let k = 0; k < change.count; k++) { + if (toNew[old + k] === undefined) + pair(old + k, now + k) + anchors.push([old + k, now + k]) + } + } + if (!change.added) + old += change.count + if (!change.removed) + now += change.count + } + + let lastOld = -1 + let lastNew = -1 + for (const [anchorOld, anchorNew] of [...anchors, [before.length, after.length]]) { + let from = lastNew + 1 + for (let o = lastOld + 1; o < anchorOld; o++) { + if (toNew[o] !== undefined || oldNames[o] !== undefined) + continue + let best: number | undefined + let bestScore = 0 + for (let n = from; n < Math.min(anchorNew, from + SIMILAR_WINDOW); n++) { + if (toOld[n] !== undefined || newNames[n] !== undefined || after[n].kind !== before[o].kind) + continue + const score = similarity(before[o].code, after[n].code) + if (score >= SIMILAR && score > bestScore) { + best = n + bestScore = score + } + } + if (best !== undefined) { + pair(o, best) + from = best + 1 + } + } + lastOld = anchorOld + lastNew = anchorNew + } + return { toOld, toNew } +} + +/** 新增的一块连同子孙:最外面那块挂「新增」小标,子孙跟着算新增。 */ +function markAdded(view: BlockView, outermost: boolean, blocks: Record): void { + blocks[view.anchor] = { change: 'add', ...(outermost && { outermost: true as const }) } + for (const child of view.children) + markAdded(child, false, blocks) +} + +/** + * 两棵块树比:新增、修改的块按新块的记号记下,删掉的块放回原来的位置。 + * 统计只数最外层带标记的块(见 DiffStats)。 + */ +export function diffBlockTrees(before: SourcedBlocks, after: SourcedBlocks): TreeDiff { + const blocks: Record = {} + const removed: RemovedBlock[] = [] + const stats: DiffStats = { add: 0, mod: 0, del: 0 } + + /** + * 比一层兄弟块:parent 是这一层所属的新块(顶层没有),inside 表示外层已经带了标记(这一层的改动不再计数)。 + * @returns 这一层有没有块新增、修改、删除 + */ + const level = (old: readonly BlockView[], now: readonly BlockView[], parent: BlockView | undefined, inside: boolean): boolean => { + const { toOld, toNew } = alignSiblings(old, before.source, now, after.source) + let changed = false + now.forEach((view, index) => { + const paired = toOld[index] + if (paired === undefined) { + markAdded(view, true, blocks) + changed = true + if (!inside) + stats.add += 1 + return + } + const previous = old[paired] + if (previous.code === view.code) + return + const marks = blockMarks(previous, view) + const childrenChanged = level(previous.children, view.children, view, true) + blocks[view.anchor] = { change: 'mod', ...((marks || !childrenChanged) && { own: true as const }), ...(marks && { marks }) } + changed = true + if (!inside) + stats.mod += 1 + }) + // 删掉的块排在它前面最近一个配上的兄弟所配的新块后面,再越过紧跟着的新增块:落在原来前后两个邻居中间,新增的在前、删掉的在后 + const throughAdded = (from: number): BlockView | undefined => { + let end = from + while (end + 1 < now.length && toOld[end + 1] === undefined) + end += 1 + return end >= 0 ? now[end] : undefined + } + let last = throughAdded(-1) + old.forEach((view, index) => { + const paired = toNew[index] + if (paired !== undefined) { + last = throughAdded(paired) + return + } + removed.push({ + parent: parent?.anchor ?? '', + ...(last && { after: last.anchor }), + block: removedView(view), + line: last ? last.endLine + 1 : parent ? parent.headerEndLine + 1 : 0, + }) + changed = true + if (!inside) + stats.del += 1 + }) + return changed + } + + level(before.blocks, after.blocks, undefined, false) + return { blocks, removed, stats } +} diff --git a/vscode/src/codeReader/diff/diffBase.ts b/vscode/src/codeReader/diff/diffBase.ts new file mode 100644 index 0000000..0851b2f --- /dev/null +++ b/vscode/src/codeReader/diff/diffBase.ts @@ -0,0 +1,14 @@ +/** + * 对比基准:侧栏只列改动的文件时(「未提交的更改」对比上一次提交,「选工单」审查对比 PR 的目标分支), + * 预览拿它取这个文件改之前的样子,和现在的比出差异。由开着的那种侧栏筛选给(TreeFilter.diffBase)。 + */ +export interface DiffBase { + /** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */ + label: string + /** 同一个基准里的同一个文件 key 一样:取过的旧内容按它记着,基准挪了(提交后 HEAD 动了)key 跟着变 */ + key: string + /** 基准里这个文件的内容;基准里没有这个文件是 null */ + read: () => Promise + /** 基准里没有这个文件时横幅上的一句说明 */ + missing: string +} diff --git a/vscode/src/codeReader/diff/previewDiff.test.ts b/vscode/src/codeReader/diff/previewDiff.test.ts new file mode 100644 index 0000000..60b90f7 --- /dev/null +++ b/vscode/src/codeReader/diff/previewDiff.test.ts @@ -0,0 +1,113 @@ +import type { DiffBase } from './diffBase' +import type { DocumentDiff, OldDocument, SentDocument } from './previewDiff' +import { describe, expect, it, vi } from 'vitest' +import { fileAttributes } from '../attributes' +import { readBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { runtime, WASM_DIR } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { PreviewDiff } from './previewDiff' + +const py = createPythonAdapter(WASM_DIR) + +async function parse(source: string): Promise { + const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source) + return { blocks, attributes: fileAttributes(blocks, pythonZhTranslator.attributeNames) } +} + +async function sent(source: string, version = 1): Promise { + return { version, source, blocks: (await parse(source)).blocks } +} + +function base(text: string | null, key = 'k'): DiffBase & { read: ReturnType } { + return { label: '上一次提交', key, read: vi.fn(async () => text), missing: '上一次提交里没有这个文件' } +} + +function setup(current: { base: DiffBase | undefined }) { + const posted: (DocumentDiff | null)[] = [] + const reports: string[] = [] + const diff = new PreviewDiff(() => current.base, parse, value => posted.push(value), message => reports.push(message)) + return { diff, posted, reports } +} + +describe('previewDiff', () => { + it('比出差异发出去:统计、删掉的块带种类的名字、新文件一共几行', async () => { + const current = { base: base('def f():\n return 1\n\n\ndef g():\n pass\n') as DiffBase | undefined } + const { diff, posted } = setup(current) + await diff.update(await sent('def f():\n return 2\n')) + expect(posted).toHaveLength(1) + expect(posted[0]).toMatchObject({ base: '上一次提交', version: 1, stats: { add: 0, mod: 1, del: 1 }, lineCount: 3 }) + expect(posted[0]?.removed[0]).toMatchObject({ kindName: '函数', after: 'function f' }) + }) + + it('基准里没有这个文件:整份是新文件,不逐块标', async () => { + const { diff, posted } = setup({ base: base(null) }) + await diff.update(await sent('x = 1\n')) + expect(posted).toEqual([{ base: '上一次提交', version: 1, lineCount: 2, newFile: '上一次提交里没有这个文件', blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } }]) + }) + + it('没有基准时不发;发过差异后基准没了发一次 null', async () => { + const current: { base: DiffBase | undefined } = { base: undefined } + const { diff, posted } = setup(current) + const doc = await sent('x = 1\n') + await diff.update(doc) + expect(posted).toEqual([]) + current.base = base('x = 1\n') + await diff.update(doc) + expect(posted).toHaveLength(1) + current.base = undefined + await diff.update(doc) + await diff.update(doc) + expect(posted.at(-1)).toBeNull() + expect(posted).toHaveLength(2) + }) + + it('旧内容按基准的 key 记着:文档改了不再取,key 变了重取', async () => { + const first = base('x = 1\n', 'a') + const current: { base: DiffBase | undefined } = { base: first } + const { diff } = setup(current) + await diff.update(await sent('x = 2\n', 1)) + await diff.update(await sent('x = 3\n', 2)) + expect(first.read).toHaveBeenCalledTimes(1) + const second = base('x = 1\n', 'b') + current.base = second + await diff.update(await sent('x = 3\n', 2)) + expect(second.read).toHaveBeenCalledTimes(1) + }) + + it('同一份文档、同一个基准不再比', async () => { + const { diff, posted } = setup({ base: base('x = 1\n') }) + const doc = await sent('x = 2\n') + await diff.update(doc) + await diff.update(doc) + expect(posted).toHaveLength(1) + }) + + it('取不到旧内容:记一笔,清掉发过的差异,下次重取', async () => { + const failing: DiffBase = { label: '上一次提交', key: 'k', read: vi.fn().mockRejectedValueOnce(new Error('git 挂了')).mockResolvedValue('x = 1\n'), missing: '' } + const current: { base: DiffBase | undefined } = { base: base('x = 1\n', 'ok') } + const { diff, posted, reports } = setup(current) + const doc = await sent('x = 2\n') + await diff.update(doc) + current.base = failing + await diff.update(doc) + expect(reports[0]).toContain('git 挂了') + expect(posted.at(-1)).toBeNull() + await diff.update(doc) + expect(failing.read).toHaveBeenCalledTimes(2) + expect(posted.at(-1)).toMatchObject({ stats: { mod: 1 } }) + }) + + it('慢的旧结果不盖掉新的', async () => { + let release: (text: string) => void = () => {} + const slow: DiffBase = { label: '上一次提交', key: 'slow', read: () => new Promise(resolve => release = resolve), missing: '' } + const current: { base: DiffBase | undefined } = { base: slow } + const { diff, posted } = setup(current) + const pending = diff.update(await sent('x = 2\n', 1)) + current.base = base('x = 1\n', 'fast') + await diff.update(await sent('x = 2\n', 2)) + release('x = 1\n') + await pending + expect(posted.map(value => value?.version)).toEqual([2]) + }) +}) diff --git a/vscode/src/codeReader/diff/previewDiff.ts b/vscode/src/codeReader/diff/previewDiff.ts new file mode 100644 index 0000000..60cc522 --- /dev/null +++ b/vscode/src/codeReader/diff/previewDiff.ts @@ -0,0 +1,138 @@ +import type { FileAttribute } from '../attributes' +import type { BlockView } from '../blockView' +import type { ChangedBlock, DiffStats, RemovedBlock } from './blockDiff' +import type { DiffBase } from './diffBase' +import { splitLines } from '../editText' +import { diffBlockTrees } from './blockDiff' + +/** 删掉的一块(见 RemovedBlock),带上种类的名字:旧版本里才有的种类,新文件的筛选属性里查不到它叫什么。 */ +export interface RemovedView extends RemovedBlock { + kindName: string +} + +/** + * 发给预览的差异:这份文件相比对比基准(DiffBase)改了什么。块按记号(BlockView.anchor)认,文档刚改、差异还没重算时 + * 没动过的块照样对得上;卡片里的标记按下标认,只在页面上正是 version 这一版时画。 + */ +export interface DocumentDiff { + /** 「对比:」后面的字 */ + base: string + /** 算的是哪一版文档 */ + version: number + /** 基准里没有这个文件时横幅上的说明;这时整份都是新增,不逐块标 */ + newFile?: string + /** 新增、修改的块,按新块的记号 */ + blocks: Record + removed: RemovedView[] + stats: DiffStats + /** 新文件一共几行:总览条按它排刻度 */ + lineCount: number +} + +/** 发出去的一份文档:版本、块树、源码。 */ +export interface SentDocument { + version: number + blocks: BlockView[] + source: string +} + +/** 旧版本解析出来的块树和筛选属性(种类的名字从这取)。 */ +export interface OldDocument { + blocks: BlockView[] + attributes: FileAttribute[] +} + +const NO_CHANGES: DiffStats = { add: 0, mod: 0, del: 0 } + +/** 旧版本里各种类叫什么:筛选属性 kind 的各个值的名字。 */ +function kindNames(attributes: readonly FileAttribute[]): Map { + return new Map(attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])) +} + +/** 按基准比出来的差异:基准里没有这个文件时整份是新文件,否则两棵块树比(见 diffBlockTrees)。 */ +export function documentDiff(base: DiffBase, sent: SentDocument, old: { source: string, document: OldDocument } | null): DocumentDiff { + const common = { base: base.label, version: sent.version, lineCount: splitLines(sent.source).length } + if (!old) + return { ...common, newFile: base.missing, blocks: {}, removed: [], stats: NO_CHANGES } + const { blocks, removed, stats } = diffBlockTrees({ blocks: old.document.blocks, source: old.source }, sent) + const kinds = kindNames(old.document.attributes) + return { ...common, blocks, removed: removed.map(item => ({ ...item, kindName: kinds.get(item.block.kind) ?? item.block.kind })), stats } +} + +/** + * 一个打开着的预览的差异:每发出一份文档之后、对比基准变了之后按眼下的基准重新比,比完再发,预览先照常画、不等它。 + * 取过的旧内容按基准的 key 记着(编辑时不用每次去问 git),旧版本每次重新解析:规则查到的定义、调用的种类这些晚到的答案两边要一样。 + * 这里不碰 VS Code。 + */ +export class PreviewDiff { + /** 取过的旧内容;key 相同不再取,取失败了下次重取 */ + private old: { key: string, text: Promise } | undefined + /** 只发最后一次比的结果:慢的旧结果不能盖掉新的 */ + private latest = 0 + /** 上一次比的是哪份文档、哪个基准:都没变就不再比(git 状态变了、基准没挪) */ + private compared: { sent: SentDocument, base: string | undefined } | undefined + /** 发过差异(不是 null):基准没了时要发一次 null 清掉 */ + private showing = false + + constructor( + /** 眼下对比的基准;没有是不比(普通文件树) */ + private readonly baseOf: () => DiffBase | undefined, + /** 用这份文档的语言、翻译插件和库规则解析旧版本;解析失败是 undefined */ + private readonly readOld: (source: string) => Promise, + private readonly post: (diff: DocumentDiff | null) => void, + private readonly report: (message: string) => void, + ) {} + + /** 按眼下的基准比 sent 这份文档,比完发出去;取不到旧内容、旧版本解析失败时记一笔,发 null。 */ + async update(sent: SentDocument): Promise { + const base = this.baseOf() + const signature = base && [base.key, base.label, base.missing].join('\u0000') + if (this.compared?.sent === sent && this.compared.base === signature) + return + this.compared = { sent, base: signature } + const request = ++this.latest + if (!base) { + this.clear() + return + } + try { + const text = await this.oldText(base) + const document = text === null ? undefined : await this.readOld(text) + if (request !== this.latest) + return + if (text !== null && !document) { + this.report(`没能解析${base.label}里的旧版本,不标差异`) + this.clear() + return + } + this.showing = true + this.post(documentDiff(base, sent, text === null || !document ? null : { source: text, document })) + } + catch (err) { + if (request !== this.latest) + return + this.report(`没能取到${base.label}里的旧版本,不标差异:${err instanceof Error ? err.message : String(err)}`) + this.compared = undefined + this.clear() + } + } + + private clear(): void { + if (!this.showing) + return + this.showing = false + this.post(null) + } + + private oldText(base: DiffBase): Promise { + if (this.old?.key !== base.key) { + const text = base.read() + this.old = { key: base.key, text } + text.catch(() => { + if (this.old?.text === text) + this.old = undefined + }) + } + return this.old.text + } +} diff --git a/vscode/src/codeReader/diff/viewDiff.test.ts b/vscode/src/codeReader/diff/viewDiff.test.ts new file mode 100644 index 0000000..3a77d2d --- /dev/null +++ b/vscode/src/codeReader/diff/viewDiff.test.ts @@ -0,0 +1,187 @@ +import type { BlockView } from '../blockView' +import type { Inline, PropertyItem, PropertyRow, Segment, TokenRole } from '../contract' +import { describe, expect, it } from 'vitest' +import { blockMarks, inlineDiff, itemsDiff, markdownDiff, pairByKey, removedItems, removedView, rowsDiff, textDiff, withoutSource } from './viewDiff' + +function seg(text: string, role: TokenRole = 'plain', offset?: number): Segment { + return { text, role, ...(offset !== undefined && { offset }) } +} + +function item(...cells: string[]): PropertyItem { + return { cells: cells.map(cell => [seg(cell, 'variable')]) } +} + +function row(label: string, value: Inline[] | { markdown: string }, items?: PropertyItem[]): PropertyRow { + return { label, value, ...(items && { items }) } +} + +function textView(translation: Inline[] | { markdown: string }, extra: Partial = {}): BlockView { + return { id: '0', anchor: 'a', kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], translation, ...extra } as BlockView +} + +function propertyView(properties: PropertyRow[]): BlockView { + return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], properties } +} + +describe('inlineDiff', () => { + it('一样(只是源码下标不同)时没有改动', () => { + expect(inlineDiff([seg('a', 'variable', 1)], [seg('a', 'variable', 9)])).toBeUndefined() + }) + + it('大部分没变时逐截标:没变的、新加的照新版本(带下标),删掉的取旧版本、去掉下标', () => { + const before = [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5), seg(')')] + const after = [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5), seg(', '), seg('token', 'variable', 10), seg(')')] + expect(inlineDiff(before, after)).toEqual([ + { segments: [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5)] }, + { segments: [seg(', '), seg('token', 'variable', 10)], change: 'add' }, + { segments: [seg(')')] }, + ]) + }) + + it('改得多时整段写成旧值 → 新值,旧值去掉下标', () => { + expect(inlineDiff([seg('布尔', 'type', 3)], [seg('字符串', 'type'), seg(' | '), seg('空', 'storage')])).toEqual([ + { segments: [seg('布尔', 'type')], change: 'del' }, + { segments: [seg('字符串', 'type'), seg(' | '), seg('空', 'storage')], change: 'add' }, + ]) + }) + + it('框按样式、框头和框里的词比', () => { + const group = (label: string): Inline => ({ style: 'iteration', label, segments: [seg('x', 'variable', 2)] }) + expect(inlineDiff([group('列表')], [group('列表')])).toBeUndefined() + expect(inlineDiff([group('列表')], [group('生成器')])).toHaveLength(2) + }) +}) + +describe('markdownDiff', () => { + it('按词比,中文按字', () => { + expect(markdownDiff('Lua 比对 token 再删.', 'Lua 比对 token 再续期 / 再删.')).toEqual([ + { text: 'Lua 比对 token 再' }, + { text: '续期 / 再', change: 'add' }, + { text: '删.' }, + ]) + }) + + it('删掉的字取旧版本', () => { + expect(markdownDiff('放锁前必须比对', '放锁都必须比对')).toEqual([ + { text: '放锁' }, + { text: '前', change: 'del' }, + { text: '都', change: 'add' }, + { text: '必须比对' }, + ]) + }) + + it('一样时没有改动', () => { + expect(markdownDiff('同样', '同样')).toBeUndefined() + }) +}) + +describe('textDiff', () => { + it('着色的词逐截比,Markdown 逐词比,一种换成另一种时按字逐词比', () => { + expect(textDiff([seg('a')], [seg('b')])).toHaveProperty('inline') + expect(textDiff({ markdown: 'a' }, { markdown: 'b' })).toHaveProperty('markdown') + expect(textDiff([seg('无', 'hint')], { markdown: '说明' })).toEqual({ markdown: [{ text: '无', change: 'del' }, { text: '说明', change: 'add' }] }) + }) +}) + +describe('pairByKey / removedItems', () => { + it('同 key 照先后配;没配上的旧项排在它前面最近配上的那项所配的新项后面', () => { + const before = ['a', 'x', 'b', 'y'] + const after = ['b', 'a', 'c'] + const { toOld, toNew } = pairByKey(before, after, value => value) + expect(toOld).toEqual([2, 0, undefined]) + expect(toNew).toEqual([1, undefined, 0, undefined]) + expect(removedItems(before, toNew, value => value)).toEqual([{ after: 1, value: 'x' }, { after: 0, value: 'y' }]) + }) + + it('最前面没配上的排在最前面', () => { + const { toNew } = pairByKey(['x', 'a'], ['a'], value => value) + expect(removedItems(['x', 'a'], toNew, value => value)).toEqual([{ after: -1, value: 'x' }]) + }) +}) + +describe('itemsDiff', () => { + it('按第一列配:新参数算加行,没了的参数算删行,配上的逐格比', () => { + const before = [item('redis', 'Any'), item('ttl', '整数')] + const after = [item('redis', 'Any'), item('ttl_sec', '整数'), item('prefix', '字符串')] + const diff = itemsDiff(before, after) + expect(diff?.changed).toEqual({ 1: { added: true }, 2: { added: true } }) + expect(diff?.removed).toEqual([{ after: 0, value: item('ttl', '整数') }]) + }) + + it('配上的行某一格变了只标那一格', () => { + const diff = itemsDiff([item('key', '字符串')], [item('key', '字节串')]) + expect(Object.keys(diff?.changed[0].cells ?? {})).toEqual(['1']) + }) + + it('新加的行下面各层也是新加的', () => { + const added: PropertyItem = { cells: [[seg('k')]], items: [item('inner')], table: { columns: ['名称'], items: [item('arg')] } } + expect(itemsDiff([], [added])?.changed[0]).toEqual({ + added: true, + items: { changed: { 0: { added: true } }, removed: [] }, + table: { changed: { 0: { added: true } }, removed: [] }, + }) + }) + + it('都没有、或一样时没有改动', () => { + expect(itemsDiff(undefined, undefined)).toBeUndefined() + expect(itemsDiff([item('a')], [item('a')])).toBeUndefined() + }) +}) + +describe('rowsDiff', () => { + it('按属性名配:值变了逐截标,多出来的行算加行,没了的行算删行', () => { + const before = [row('名称', [seg('f', 'function')]), row('输出参数', [seg('无', 'hint')]), row('装饰器', [seg('@x')])] + const after = [row('名称', [seg('f', 'function')]), row('异步', [seg('是')]), row('输出参数', [seg('布尔', 'type')])] + const diff = rowsDiff(before, after) + expect(diff?.changed[1]).toEqual({ added: true }) + expect(diff?.changed[2].value).toBeDefined() + expect(diff?.removed).toEqual([{ after: 2, value: row('装饰器', [seg('@x')]) }]) + }) + + it('子行改了时只标子行,值(子行的汇总)不另标', () => { + const before = [row('输入参数', [seg('1 个', 'hint')], [item('a')])] + const after = [row('输入参数', [seg('2 个', 'hint')], [item('a'), item('b')])] + expect(rowsDiff(before, after)?.changed[0]).toEqual({ items: { changed: { 1: { added: true } }, removed: [] } }) + }) + + it('新加的行带着子行时,子行也都是新加的', () => { + const diff = rowsDiff([], [row('输入参数', [seg('1 个')], [item('a')])]) + expect(diff?.changed[0]).toEqual({ added: true, items: { changed: { 0: { added: true } }, removed: [] } }) + }) +}) + +describe('blockMarks', () => { + it('一行译文、摊开实参的表、说明各比各的', () => { + const before = textView([seg('调用 f')], { doc: { markdown: '旧说明' } } as Partial) + const after = textView([seg('调用 g')], { table: { columns: ['实参'], items: [item('x')] } } as Partial) + const marks = blockMarks(before, after) + expect(marks?.translation).toBeDefined() + expect(marks?.table?.changed).toEqual({ 0: { added: true } }) + expect(marks?.doc).toEqual({ markdown: [{ text: '旧说明', change: 'del' }] }) + }) + + it('一块是属性表、一块是一行译文时没法逐行比', () => { + expect(blockMarks(textView([seg('a')]), propertyView([row('名称', [seg('a')])]))).toBeUndefined() + }) + + it('内容一样时没有标记', () => { + expect(blockMarks(propertyView([row('名称', [seg('a', 'variable', 1)])]), propertyView([row('名称', [seg('a', 'variable', 7)])]))).toBeUndefined() + }) +}) + +describe('removedView / withoutSource', () => { + it('删掉的块只带自己的内容,词不带源码下标,没有名字的范围', () => { + const view = propertyView([row('名称', [seg('f', 'function', 4)], [{ cells: [[{ ...seg('x', 'variable', 9), refers: { startIndex: 1, endIndex: 2 } }]] }])]) + const removed = removedView({ ...view, defines: { startIndex: 4, endIndex: 5 }, children: [textView([seg('child', 'plain', 1)])] }) + expect(removed.children).toEqual([]) + expect(removed.defines).toBeUndefined() + expect(JSON.stringify(removed)).not.toMatch(/"offset"|"refers"/) + }) + + it('框里的词也去掉下标,颜色留着', () => { + const color = { dark: '#fff', light: '#000' } + expect(withoutSource([{ style: 'iteration', label: '列表', segments: [{ ...seg('x', 'variable', 3), color }] }])).toEqual([ + { style: 'iteration', label: '列表', segments: [{ text: 'x', role: 'variable', color }] }, + ]) + }) +}) diff --git a/vscode/src/codeReader/diff/viewDiff.ts b/vscode/src/codeReader/diff/viewDiff.ts new file mode 100644 index 0000000..549b3b6 --- /dev/null +++ b/vscode/src/codeReader/diff/viewDiff.ts @@ -0,0 +1,311 @@ +import type { BlockView } from '../blockView' +import type { Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow } from '../contract' +import { diffArrays, diffWordsWithSpace } from 'diff' + +/** + * 一块卡片里的内容改了哪些:在渲染用的视图数据这一层比(翻译好的属性表、一行译文、摊开实参的表、说明), + * 所有语言、库规则的卡片走同一条路,不认识任何语言。这里不碰 VS Code。 + */ + +/** 一截是新加的还是删掉的;没变的不写。 */ +export type PartChange = 'add' | 'del' + +/** 着色的词里的一截:没变的、新加的(照新版本,带源码下标),或删掉的(取自旧版本,去掉了指向源码的下标)。 */ +export interface InlinePart { segments: Inline[], change?: PartChange } + +/** Markdown 说明里的一截:按词切,中文按字。 */ +export interface MarkdownPart { text: string, change?: PartChange } + +/** 一段预览文字改成了什么样:着色的词逐截标(改得多就是整段旧值 → 新值),Markdown 说明逐词 / 字标。 */ +export type TextDiff = { inline: InlinePart[] } | { markdown: MarkdownPart[] } + +/** 删掉的一行 / 子行:旧的那行,和它排在新的第几行后面(-1 是最前面)。 */ +export interface Removed { after: number, value: T } + +/** 一层子行(参数表、导入项、字典的各项、摊开实参的表)的改动。 */ +export interface ItemsDiff { + /** 按新子行的下标;没变的不写 */ + changed: Record + removed: Removed[] +} + +export interface ItemDiff { + /** 整行是新加的(它下面各层也都是) */ + added?: true + /** 按列:这一格改成了什么样 */ + cells?: Record + items?: ItemsDiff + table?: ItemsDiff +} + +/** 属性表各行的改动。 */ +export interface RowsDiff { + /** 按新属性表的行下标;没变的不写 */ + changed: Record + removed: Removed[] +} + +export interface RowDiff { + /** 整行是新加的(它下面的子行也都是) */ + added?: true + value?: TextDiff + items?: ItemsDiff +} + +/** 一块自己的内容(不算子块)改了哪些:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */ +export interface BlockMarks { + rows?: RowsDiff + translation?: TextDiff + table?: ItemsDiff + doc?: TextDiff +} + +/** 共同的部分少于长的那边的这么多,就不逐截标,整段写成旧值 → 新值。 */ +const PARTS_MIN_SHARE = 0.4 + +/** 旧版本里的词:去掉指向源码的下标(offset、refers、reference),旧的下标对不上现在的文件,不能拿去跳定义、高亮。 */ +export function withoutSource(pieces: readonly Inline[]): Inline[] { + return pieces.map((piece) => { + if (!('role' in piece)) + return { ...piece, segments: withoutSource(piece.segments) } + const { text, role, color } = piece + return { text, role, ...(color && { color }) } + }) +} + +function textWithoutSource(text: PreviewText): PreviewText { + return 'markdown' in text ? text : withoutSource(text) +} + +function itemWithoutSource(item: PropertyItem): PropertyItem { + return { + cells: item.cells.map(withoutSource), + ...(item.items && { items: item.items.map(itemWithoutSource) }), + ...(item.table && { table: tableWithoutSource(item.table) }), + } +} + +function tableWithoutSource(table: PreviewTable): PreviewTable { + return { columns: table.columns, items: table.items.map(itemWithoutSource) } +} + +function rowWithoutSource(row: PropertyRow): PropertyRow { + return { + label: row.label, + value: textWithoutSource(row.value), + ...(row.items && { items: row.items.map(itemWithoutSource) }), + ...(row.columns && { columns: row.columns }), + } +} + +/** + * 旧版本里的一块,拿来画删掉的块:只留它自己的内容(子块去掉),词去掉指向源码的下标,也不带名字的范围(defines)。 + */ +export function removedView(view: BlockView): BlockView { + const { defines: _defines, children: _children, ...rest } = view + const base = { ...rest, children: [] } + if ('properties' in view) + return { ...base, properties: view.properties.map(rowWithoutSource) } + return { + ...base, + translation: textWithoutSource(view.translation), + ...(view.table && { table: tableWithoutSource(view.table) }), + ...(view.doc && { doc: view.doc }), + } +} + +/** 一个词比较用的样子:词性和字(框是样式、框头和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */ +function pieceKey(piece: Inline): string { + return 'role' in piece ? `${piece.role}\u0000${piece.text}` : `${piece.style}\u0000${piece.label}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]` +} + +/** 着色的词拼成的字(框只算框里的词)。 */ +export function inlineText(pieces: readonly Inline[]): string { + return pieces.map(piece => 'role' in piece ? piece.text : inlineText(piece.segments)).join('') +} + +function sameInline(a: readonly Inline[], b: readonly Inline[]): boolean { + return a.length === b.length && a.every((piece, i) => pieceKey(piece) === pieceKey(b[i])) +} + +/** 挨着的同一种截并成一截,空的截去掉。 */ +function mergeParts(parts: readonly InlinePart[]): InlinePart[] { + const merged: InlinePart[] = [] + for (const part of parts) { + if (part.segments.length === 0) + continue + const last = merged.at(-1) + if (last && last.change === part.change) + last.segments = [...last.segments, ...part.segments] + else + merged.push({ segments: [...part.segments], ...(part.change && { change: part.change }) }) + } + return merged +} + +/** + * 着色的词前后两版比:按词(一段着色的词是一个)对齐,没变的照新版本,删掉的取旧版本(去掉下标)、新加的照新版本; + * 没变的字少于长的那边的 PARTS_MIN_SHARE 时不逐截标,整段写成旧值 → 新值。一样时是 undefined。 + */ +export function inlineDiff(before: readonly Inline[], after: readonly Inline[]): InlinePart[] | undefined { + if (sameInline(before, after)) + return undefined + const changes = diffArrays([...before], [...after], { comparator: (a, b) => pieceKey(a) === pieceKey(b) }) + const kept = changes.filter(change => !change.added && !change.removed).reduce((sum, change) => sum + inlineText(change.value).length, 0) + const longer = Math.max(inlineText(before).length, inlineText(after).length) + if (kept < longer * PARTS_MIN_SHARE) + return mergeParts([{ segments: withoutSource(before), change: 'del' }, { segments: [...after], change: 'add' }]) + return mergeParts(changes.map((change) => { + if (change.removed) + return { segments: withoutSource(change.value), change: 'del' } + return change.added ? { segments: change.value, change: 'add' } : { segments: change.value } + })) +} + +/** Markdown 说明前后两版比:按词切(中文按字、标点各算一个),删掉的取旧版本的字。一样时是 undefined。 */ +export function markdownDiff(before: string, after: string): MarkdownPart[] | undefined { + if (before === after) + return undefined + return diffWordsWithSpace(before, after).map((change) => { + if (change.removed) + return { text: change.value, change: 'del' } + return change.added ? { text: change.value, change: 'add' } : { text: change.value } + }) +} + +/** 一段预览文字前后两版比:都是着色的词逐截比,都是 Markdown 逐词比;一种换成了另一种时按字逐词比。一样时是 undefined。 */ +export function textDiff(before: PreviewText, after: PreviewText): TextDiff | undefined { + if (!('markdown' in before) && !('markdown' in after)) { + const inline = inlineDiff(before, after) + return inline && { inline } + } + const plain = (text: PreviewText): string => 'markdown' in text ? text.markdown : inlineText(text) + const markdown = markdownDiff(plain(before), plain(after)) + return markdown && { markdown } +} + +/** 两串东西配上的:新的第几个配旧的第几个(没配上是 undefined),反过来也记一份。 */ +export interface Pairing { + toOld: (number | undefined)[] + toNew: (number | undefined)[] +} + +/** 按 key 配对:key 一样的照先后一一配上(两个同名参数,第一个配第一个)。 */ +export function pairByKey(before: readonly T[], after: readonly T[], key: (item: T) => string): Pairing { + const waiting = new Map() + before.forEach((item, index) => waiting.set(key(item), [...waiting.get(key(item)) ?? [], index])) + const toOld = after.map(item => waiting.get(key(item))?.shift()) + const toNew: (number | undefined)[] = before.map(() => undefined) + toOld.forEach((old, index) => { + if (old !== undefined) + toNew[old] = index + }) + return { toOld, toNew } +} + +/** 旧的里没配上的,各排在旧版本里它前面最近一个配上的那项所配的新项后面(前面没有配上的就排最前面,after 是 -1)。 */ +export function removedItems(before: readonly T[], toNew: readonly (number | undefined)[], strip: (item: T) => T): Removed[] { + const removed: Removed[] = [] + let after = -1 + before.forEach((item, index) => { + const paired = toNew[index] + if (paired === undefined) + removed.push({ after, value: strip(item) }) + else + after = paired + }) + return removed +} + +/** 整层子行都是新加的(新加的行下面各层也是)。 */ +function allAdded(items: readonly PropertyItem[] | undefined): ItemsDiff | undefined { + if (!items?.length) + return undefined + return { changed: Object.fromEntries(items.map((item, index) => [index, addedItem(item)])), removed: [] } +} + +function addedItem(item: PropertyItem): ItemDiff { + const items = allAdded(item.items) + const table = allAdded(item.table?.items) + return { added: true, ...(items && { items }), ...(table && { table }) } +} + +/** 配上的两个子行比:各格逐截比,下一层、套着的表逐行比。一样时是 undefined。 */ +function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefined { + const cells: Record = {} + after.cells.forEach((cell, index) => { + const parts = inlineDiff(before.cells[index] ?? [], cell) + if (parts) + cells[index] = parts + }) + const items = itemsDiff(before.items, after.items) + const table = itemsDiff(before.table?.items, after.table?.items) + const diff: ItemDiff = { + ...(Object.keys(cells).length > 0 && { cells }), + ...(items && { items }), + ...(table && { table }), + } + return Object.keys(diff).length > 0 ? diff : undefined +} + +/** + * 一层子行前后两版比:按第一列的字配对(参数按名字、导入项按名称、字典按键),配上的逐格比, + * 新版本多出来的算新加的行,旧版本里没配上的算删掉的行、排回原来的位置。一样时是 undefined。 + */ +export function itemsDiff(before: readonly PropertyItem[] | undefined, after: readonly PropertyItem[] | undefined): ItemsDiff | undefined { + const old = before ?? [] + const now = after ?? [] + const { toOld, toNew } = pairByKey(old, now, item => inlineText(item.cells[0] ?? [])) + const changed: Record = {} + now.forEach((item, index) => { + const paired = toOld[index] + const diff = paired === undefined ? addedItem(item) : itemDiff(old[paired], item) + if (diff) + changed[index] = diff + }) + const removed = removedItems(old, toNew, itemWithoutSource) + return Object.keys(changed).length > 0 || removed.length > 0 ? { changed, removed } : undefined +} + +/** + * 配上的两行比:下面的子行逐行比,值逐截 / 逐词比。子行改了时只标子行:带子行的行,值是子行的汇总(「3 个」「字典 2 项」), + * 跟着子行变,不另标。一样时是 undefined。 + */ +function rowDiff(before: PropertyRow, after: PropertyRow): RowDiff | undefined { + const items = itemsDiff(before.items, after.items) + if (items) + return { items } + const value = textDiff(before.value, after.value) + return value && { value } +} + +/** 属性表前后两版比:按属性名配对,配上的比值和子行,多出来的行算新加的,没了的行算删掉的、排回原来的位置。一样时是 undefined。 */ +export function rowsDiff(before: readonly PropertyRow[], after: readonly PropertyRow[]): RowsDiff | undefined { + const { toOld, toNew } = pairByKey(before, after, row => row.label) + const changed: Record = {} + after.forEach((row, index) => { + const paired = toOld[index] + const items = paired === undefined ? allAdded(row.items) : undefined + const diff = paired === undefined ? { added: true as const, ...(items && { items }) } : rowDiff(before[paired], row) + if (diff) + changed[index] = diff + }) + const removed = removedItems(before, toNew, rowWithoutSource) + return Object.keys(changed).length > 0 || removed.length > 0 ? { changed, removed } : undefined +} + +/** + * 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 + * 一样(或一块是属性表、一块是一行译文,没法逐行比)时是 undefined。 + */ +export function blockMarks(before: BlockView, after: BlockView): BlockMarks | undefined { + if ('properties' in before || 'properties' in after) { + const rows = 'properties' in before && 'properties' in after ? rowsDiff(before.properties, after.properties) : undefined + return rows && { rows } + } + const translation = textDiff(before.translation, after.translation) + const table = itemsDiff(before.table?.items, after.table?.items) + const doc = textDiff(before.doc ?? { markdown: '' }, after.doc ?? { markdown: '' }) + const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }) } + return Object.keys(marks).length > 0 ? marks : undefined +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 1b3aefb..85d498e 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -5,6 +5,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude' import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' import type { ProfileChoice } from '../qa/askProfiles' import type { RunningAsk } from '../qa/asks' @@ -90,6 +91,8 @@ export interface QaMessage { * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 + * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发, + * 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件) */ export type ExtensionToWebview = | DocumentMessage @@ -103,6 +106,7 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } + | { type: 'diff', diff: DocumentDiff | null } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 4f77d96..9081dea 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,7 +1,8 @@ -import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' +import type { CustomTextEditorProvider, Disposable, Event, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' -import type { BlockView } from '../blockView' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract' +import type { DiffBase } from '../diff/diffBase' +import type { OldDocument, SentDocument } from '../diff/previewDiff' import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' import type { CodeQa } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' @@ -15,6 +16,7 @@ import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' +import { PreviewDiff } from '../diff/previewDiff' import { blockReplacement } from '../editText' import { locate } from '../folderStore' import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols' @@ -33,6 +35,12 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' /** 连续输入时合并成一次重新解析。 */ const REFRESH_DELAY_MS = 150 +/** 预览对比什么:侧栏开着的筛选定(TreeFilters),换了筛选、基准变了发 onDidChangeDiffBase。 */ +export interface DiffBases { + diffBase: (uri: Uri) => DiffBase | undefined + onDidChangeDiffBase: Event +} + /** * 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument: * 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。 @@ -57,6 +65,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly indexedNames: (languageId: string, name: string) => Promise, /** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */ readonly indexed: (index: WorkspaceIndex, key: string) => Promise, + /** 预览标差异时对比什么 */ + readonly diffBases: DiffBases, ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -127,8 +137,8 @@ class Preview { private readonly definitions: DefinitionSources /** webview 报来的视口顶部那块的起始行 */ visibleLine: number | undefined - /** 最近一次发出去的块树和它的源码:问 AI 时按块的记号在里面找那块代码 */ - private sent: { blocks: BlockView[], source: string } | undefined + /** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */ + private sent: SentDocument | undefined /** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */ private symbols: { version: number, list: LocalSymbol[] } | undefined /** 这份文档各版本显示成中文名的名字 */ @@ -137,6 +147,8 @@ class Preview { private readonly lookups: DocumentLookups /** 调用的名字定义成哪种块(分新建还是调用):查到的变了就重发文档 */ private readonly callees: DocumentCallees + /** 这份文件相比对比基准改了什么:发完文档再算、补发 */ + private readonly diff: PreviewDiff constructor( private readonly document: TextDocument, @@ -164,7 +176,20 @@ class Preview { }) this.lookups = new DocumentLookups(() => this.schedulePush()) this.callees = new DocumentCallees(() => this.schedulePush()) + this.diff = new PreviewDiff( + () => owner.diffBases.diffBase(document.uri), + source => this.readOld(source), + (diff) => { + if (this.ready) + this.post({ type: 'diff', diff }) + }, + text => logger.add({ level: 'warn', source: 'codeReader', message: `${document.uri.fsPath}:${text}` }), + ) this.disposables = [ + owner.diffBases.onDidChangeDiffBase(() => { + if (this.ready && this.sent) + void this.diff.update(this.sent) + }), panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), panel.onDidChangeViewState(() => { if (panel.visible) @@ -240,11 +265,14 @@ class Preview { if (request !== this.latest || !this.ready) return this.definitions.remember(message.version, message.blocks) - this.sent = { blocks: message.blocks, source } + // 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异 + this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined this.symbols = { version, list: symbols } const reveal = this.pendingReveal this.pendingReveal = undefined this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) + if (this.sent) + void this.diff.update(this.sent) // 文档在解析期间又改了的,等下一次发文档再查 if (version === this.document.version) { this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report)) @@ -253,6 +281,22 @@ class Preview { } } + /** + * 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案, + * 两边同样的写法翻得一样,不会因为答案晚到被当成改了。旧版本里规则出的错不记(记的是现在这份的)。 + */ + private async readOld(source: string): Promise { + const { message } = await documentMessage( + { languageId: this.document.languageId, version: this.document.version, getText: () => source }, + this.registry, + this.runtime, + () => {}, + this.lookups.session().lookup, + this.callees.session().calleeKind, + ) + return message.error === undefined ? { blocks: message.blocks, attributes: message.attributes } : undefined + } + /** 这份文档里 offset 处名字的定义是哪种块:语言扩展查到的几处定义里第一处认得出的;语言扩展没答是 undefined。 */ private async calleeKind(offset: number): Promise { const { document } = this @@ -360,7 +404,7 @@ class Preview { */ private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise { const at = locate(this.document.uri) - const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, ...this.sent, anchor } + const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor } const prompt = subject && askPrompt(subject, question) const followUp = subject && followUpPrompt(subject, question) const refused = prompt === undefined || followUp === undefined diff --git a/vscode/src/codeReader/explorer/treeFilters.ts b/vscode/src/codeReader/explorer/treeFilters.ts index 2dc7173..b42db05 100644 --- a/vscode/src/codeReader/explorer/treeFilters.ts +++ b/vscode/src/codeReader/explorer/treeFilters.ts @@ -1,6 +1,7 @@ -import type { TreeView } from 'vscode' +import type { Event, TreeView, Uri } from 'vscode' +import type { DiffBase } from '../diff/diffBase' import type { FileTreeProvider, TreeNode } from './fileTree' -import { commands, Disposable } from 'vscode' +import { commands, Disposable, EventEmitter } from 'vscode' /** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */ const FILTER_CONTEXT = 'superpowers.codeReader.filter' @@ -14,18 +15,39 @@ export interface TreeFilter { reload: () => Promise /** 「显示全部」或换成别的筛选:不再筛、不再记着,正在进的作废 */ exit: () => Promise + /** 开着这种筛选时 uri 这个文件的预览对比什么(见 DiffBase);不在这种筛选管的范围里(仓库外、工作副本外)时没有 */ + diffBase: (uri: Uri) => DiffBase | undefined } /** * 侧栏树上的几种筛选,同时只开一种:开一种时别的都退出;上下文键跟着开着的那种。 * 筛选开起来时自己换上树、标题后面的字(description)、标题下面的一行(message);退出时由这里回到工作区文件树。 + * 开着的筛选也定打开着的预览对比什么(diffBase),换了筛选、基准变了都通知预览重新比。 */ export class TreeFilters { private readonly filters: TreeFilter[] = [] private current: TreeFilter | undefined + private readonly baseChange = new EventEmitter() + /** 开着的筛选换了(开了、换了、关了),或它的对比基准变了(git 状态变了、审查重读了 PR):打开着的预览照 diffBase 重新比 */ + readonly onDidChangeDiffBase: Event = this.baseChange.event constructor(private readonly files: FileTreeProvider, private readonly view: TreeView) {} + /** 开着的筛选下 uri 这个文件的预览对比什么;没开筛选(全部文件)时没有,预览不标差异。 */ + diffBase(uri: Uri): DiffBase | undefined { + return this.current?.diffBase(uri) + } + + /** filter 的对比基准变了时调;它没开着就不理。 */ + baseChanged(filter: TreeFilter): void { + if (this.current === filter) + this.baseChange.fire() + } + + dispose(): void { + this.baseChange.dispose() + } + /** 开着的那种筛选;没开是全部文件 */ get active(): TreeFilter | undefined { return this.current @@ -57,6 +79,7 @@ export class TreeFilters { /** filter 换上侧栏树之前调:别的筛选都退出(同步的部分在这里就做完了,调用方紧接着换上自己的),上下文键设成 kind。 */ async enter(filter: TreeFilter, kind: TreeFilterKind): Promise { this.current = filter + this.baseChange.fire() await Promise.all([ ...this.filters.filter(other => other !== filter).map(other => other.exit()), commands.executeCommand('setContext', FILTER_CONTEXT, kind), @@ -68,6 +91,7 @@ export class TreeFilters { if (this.current !== filter) return this.current = undefined + this.baseChange.fire() this.files.showChanges(undefined) this.view.description = undefined this.view.message = undefined diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 90d3fe0..d854f20 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -84,13 +84,14 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const adapter = registry.languageFor(languageId) return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : [] } - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key)) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) const filters = new TreeFilters(files, view) const review = new ReviewMode(context, FILES_VIEW, view, files, filters) const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters) + // 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支 + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters) context.subscriptions.push( qa, @@ -105,6 +106,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews), files, view, + filters, review, uncommitted, // 开着筛选时刷新是重读它摆的文件(PR 改过的、git 状态),平时是重读工作区文件树 diff --git a/vscode/src/codeReader/review/changedFiles.test.ts b/vscode/src/codeReader/review/changedFiles.test.ts index 9236116..43c335e 100644 --- a/vscode/src/codeReader/review/changedFiles.test.ts +++ b/vscode/src/codeReader/review/changedFiles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CHANGE_STYLES, changeOf } from './changedFiles' +import { CHANGE_STYLES, changeOf, reviewBaseFile } from './changedFiles' describe('改动状态', () => { it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => { @@ -16,3 +16,18 @@ describe('改动状态', () => { expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了') }) }) + +describe('审查时在 PR 目标分支上取哪个文件', () => { + const files = [ + { path: 'src/new.ts', status: 'added' }, + { path: 'src/moved.ts', status: 'renamed', previousFilename: 'src/old.ts' }, + { path: 'src/changed.ts', status: 'changed' }, + ] + + it('新加的目标分支上没有,改名的取改名前的路径,别的照原路径', () => { + expect(reviewBaseFile(files, 'src/new.ts')).toEqual({ path: 'src/new.ts', added: true }) + expect(reviewBaseFile(files, 'src/moved.ts')).toEqual({ path: 'src/old.ts', added: false }) + expect(reviewBaseFile(files, 'src/changed.ts')).toEqual({ path: 'src/changed.ts', added: false }) + expect(reviewBaseFile(files, 'src/untouched.ts')).toEqual({ path: 'src/untouched.ts', added: false }) + }) +}) diff --git a/vscode/src/codeReader/review/changedFiles.ts b/vscode/src/codeReader/review/changedFiles.ts index c3a693c..6911b91 100644 --- a/vscode/src/codeReader/review/changedFiles.ts +++ b/vscode/src/codeReader/review/changedFiles.ts @@ -8,6 +8,15 @@ export function changeOf(status: string): FileChange { return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M' } +/** + * 审查时工作副本里的一个文件(path 相对工作副本的根)在 PR 目标分支上叫什么、有没有: + * 改名的取改名前的路径,PR 里新加的(Gitea 的 added)目标分支上没有。PR 没改过的文件照原路径取。 + */ +export function reviewBaseFile(files: readonly { path: string, status: string, previousFilename?: string }[], path: string): { path: string, added: boolean } { + const file = files.find(item => item.path === path) + return { path: file?.previousFilename ?? path, added: file?.status === 'added' } +} + /** 审查时各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */ export const CHANGE_STYLES: Record = { M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' }, diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index 3f1be97..2d45540 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -2,17 +2,20 @@ import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFo import type { PrFileDto } from '../../gitea/api' import type { RepoContext } from '../../gitea/repoContext' import type { Issue } from '../../gitea/types' +import type { DiffBase } from '../diff/diffBase' import type { FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' import type { FileCount } from './pickList' import * as path from 'node:path' import { QuickPickItemKind, Uri, window, workspace } from 'vscode' +import { fileAtRef } from '../../git/fileAt' import { logger } from '../../logging/logger' import { buildChangedTree } from '../explorer/changedTree' +import { toPathKey } from '../pathMap' import { ChangeDecorations } from './changeDecorations' -import { changeOf } from './changedFiles' +import { changeOf, reviewBaseFile } from './changedFiles' import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' -import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' +import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewDescription, reviewMessage } from './reviewRules' import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource' import { localWorktree, prepareWorktree } from './reviewWorktree' @@ -25,12 +28,16 @@ const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue' */ type How = 'pick' | 'reload' | 'restore' -/** 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本。 */ +/** + * 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本, + * 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(Gitea 没给时退回目标分支的提交,同看板「改动」tab)。 + */ interface Loaded { issue: Issue pr: number files: PrFileDto[] worktree: string + base: { ref: string, commit: string } } /** 弹框里的一行:工单的一项带着工单,分组标题(列名)没有。 */ @@ -44,14 +51,14 @@ function errorText(err: unknown): string { /** * 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色), - * 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。 + * 点开的是本机这张工单的工作副本里 PR 最新的代码,预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。选的工单按工作区记住。 * 和别的筛选(未提交的更改)互斥,见 TreeFilters。 */ export class ReviewMode implements TreeFilter, Disposable { private readonly decorations = new ChangeDecorations() private readonly disposables: Disposable[] - /** 正在审查的工单号 */ - private reviewing: number | undefined + /** 正在审查的:工单、PR、改过的文件、工作副本、对比基准 */ + private loaded: Loaded | undefined /** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */ private generation = 0 @@ -149,7 +156,7 @@ export class ReviewMode implements TreeFilter, Disposable { /** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */ async exit(): Promise { this.generation++ - this.reviewing = undefined + this.loaded = undefined this.decorations.show([]) await Promise.all([ this.filters.leave(this), @@ -159,8 +166,27 @@ export class ReviewMode implements TreeFilter, Disposable { /** 审查时的「刷新」:重读工单和 PR 改过的文件,工作副本也更新到 PR 最新的提交。 */ async reload(): Promise { - if (this.reviewing !== undefined) - await this.review(this.reviewing, 'reload') + if (this.loaded) + await this.review(this.loaded.issue.number, 'reload') + } + + /** + * 审查时,工作副本里的文件对比 PR 目标分支与 PR 头的合并基上的版本(改名的取改名前的路径);工作副本外面的文件不比。 + * 合并基是 PR 头的祖先,拉 PR 的提交时已经在本机了,直接在工作副本里取。 + */ + diffBase(uri: Uri): DiffBase | undefined { + const loaded = this.loaded + const relative = loaded && toPathKey(Uri.file(loaded.worktree).path, uri.path) + if (!loaded || relative === undefined || !loaded.base.commit) + return undefined + const { worktree, base } = loaded + const file = reviewBaseFile(loaded.files, relative) + return { + label: reviewBaseLabel(base.ref), + key: `${base.commit}:${file.path}`, + read: () => file.added ? Promise.resolve(null) : fileAtRef(worktree, base.commit, file.path), + missing: reviewBaseMissing(base.ref), + } } dispose(): void { @@ -240,17 +266,20 @@ export class ReviewMode implements TreeFilter, Disposable { if ('blocked' in reviewablePull) return reviewablePull const files = await loadPullRequestFiles(repo, pr) - const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha: reviewablePull.pull.headSha }, step) - return { issue, pr, files, worktree } + const { headSha, baseRef, mergeBase, baseSha } = reviewablePull.pull + const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha }, step) + return { issue, pr, files, worktree, base: { ref: baseRef, commit: mergeBase || baseSha } } } - private async show({ issue, pr, files, worktree }: Loaded): Promise { + private async show(loaded: Loaded): Promise { + const { issue, pr, files, worktree } = loaded const root = Uri.file(worktree) // 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码 const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 } const changes = files.map(file => ({ path: file.path, change: changeOf(file.status) })) + // 先记下再进:进的时候打开着的预览就来问对比基准 + this.loaded = loaded const entering = this.filters.enter(this, 'review') - this.reviewing = issue.number this.files.showChanges([{ roots: [{ folder, tree: buildChangedTree(changes) }] }]) this.decorations.show(changes.map(file => ({ uri: Uri.joinPath(root, file.path), change: file.change }))) this.view.description = reviewDescription(issue.number) diff --git a/vscode/src/codeReader/review/reviewRules.test.ts b/vscode/src/codeReader/review/reviewRules.test.ts index 01982c9..38c7590 100644 --- a/vscode/src/codeReader/review/reviewRules.test.ts +++ b/vscode/src/codeReader/review/reviewRules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { columnLabel, prNumberOf, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewCandidates, reviewDescription, reviewMessage } from './reviewRules' +import { columnLabel, prNumberOf, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewCandidates, reviewDescription, reviewMessage } from './reviewRules' const issue = { number: 446, title: '用户资料:上传头像,收紧非管理员改资料的权限' } @@ -83,3 +83,12 @@ describe('还能不能审查(选中、刷新、重开窗口时判断)', () = expect(reviewablePullRequest(447, { state: 'closed', merged: false })).toEqual({ blocked: 'PR #447 已经关闭' }) }) }) + +describe('审查时预览对比的基准', () => { + it('对比 PR 要合进去的目标分支;目标分支上没有的文件说一句', () => { + expect(reviewBaseLabel('master')).toBe('PR 目标分支 master') + expect(reviewBaseMissing('master')).toBe('PR 目标分支 master 里没有这个文件') + expect(reviewBaseLabel('')).toBe('PR 目标分支') + expect(reviewBaseMissing('')).toBe('PR 目标分支里没有这个文件') + }) +}) diff --git a/vscode/src/codeReader/review/reviewRules.ts b/vscode/src/codeReader/review/reviewRules.ts index e654b41..c402cf0 100644 --- a/vscode/src/codeReader/review/reviewRules.ts +++ b/vscode/src/codeReader/review/reviewRules.ts @@ -76,3 +76,13 @@ export function reviewDescription(issueNumber: number): string { export function reviewMessage(issue: Pick, pr: number, fileCount: number): string { return `#${issue.number} ${issue.title} · PR #${pr} · 改了 ${fileCount} 个文件` } + +/** 审查时预览对比的基准(筛选栏上「对比:」后面的字):PR 要合进去的目标分支。 */ +export function reviewBaseLabel(baseRef: string): string { + return baseRef ? `PR 目标分支 ${baseRef}` : 'PR 目标分支' +} + +/** PR 目标分支上没有这个文件(PR 里新加的)时横幅上的说明。 */ +export function reviewBaseMissing(baseRef: string): string { + return baseRef ? `PR 目标分支 ${baseRef} 里没有这个文件` : 'PR 目标分支里没有这个文件' +} diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts index c5fcb6c..90307cb 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts @@ -2,7 +2,7 @@ import type { GitStatus } from '../../git/gitApi' import type { ChangeGroups } from './uncommittedFiles' import { describe, expect, it } from 'vitest' import { GIT_STATUS } from '../../git/gitApi' -import { changeOfGitStatus, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles' +import { changeOfGitStatus, headMissingText, isUntracked, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles' const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status }) @@ -188,3 +188,18 @@ describe('标题后面的字、标题下面的一行', () => { expect(uncommittedMessage({ hasFiles: false, hiddenTests: 0, loading: true })).toBe('正在读取 git 状态…') }) }) + +describe('对比上一次提交', () => { + it('未跟踪的按状态认,在没暂存的那组里(mixed)、单独一组里(separate)都算', () => { + expect(isUntracked(groups({ workingTreeChanges: [at('/r/a.ts', GIT_STATUS.UNTRACKED)] }), '/r/a.ts')).toBe(true) + expect(isUntracked(groups({ untrackedChanges: [at('/r/a.ts', GIT_STATUS.UNTRACKED)] }), '/r/a.ts')).toBe(true) + expect(isUntracked(groups({ workingTreeChanges: [at('/r/a.ts', GIT_STATUS.MODIFIED)] }), '/r/a.ts')).toBe(false) + expect(isUntracked(groups({ untrackedChanges: [at('/r/b.ts', GIT_STATUS.UNTRACKED)] }), '/r/a.ts')).toBe(false) + }) + + it('上一次提交里没有这个文件时的说明', () => { + expect(headMissingText({ hasCommit: true, untracked: true })).toBe('还没被 git 跟踪,上一次提交里没有这个文件') + expect(headMissingText({ hasCommit: true, untracked: false })).toBe('上一次提交里没有这个文件') + expect(headMissingText({ hasCommit: false, untracked: true })).toBe('仓库里还没有提交') + }) +}) diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts index fe841fc..0b6041d 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts @@ -162,3 +162,18 @@ export function uncommittedMessage(state: { hasFiles: boolean, hiddenTests: numb return '除了隐藏的测试文件,没有未提交的更改' return state.loading ? '正在读取 git 状态…' : '没有未提交的更改' } + +/** 「未提交的更改」开着时预览对比的基准:上一次提交(HEAD)。 */ +export const HEAD_BASE_LABEL = '上一次提交' + +/** 文件是不是未跟踪的:设置 git.untrackedChanges 是 mixed(默认)时未跟踪的混在没暂存的那组里,按状态认。 */ +export function isUntracked(groups: Pick, filePath: string): boolean { + return [groups.workingTreeChanges, groups.untrackedChanges].some(group => group.some(entry => entry.uri.path === filePath && entry.status === GIT_STATUS.UNTRACKED)) +} + +/** 上一次提交里没有这个文件时横幅上的说明:仓库还没有提交、文件还没被 git 跟踪各说一句。 */ +export function headMissingText(state: { hasCommit: boolean, untracked: boolean }): string { + if (!state.hasCommit) + return '仓库里还没有提交' + return state.untracked ? '还没被 git 跟踪,上一次提交里没有这个文件' : '上一次提交里没有这个文件' +} diff --git a/vscode/src/codeReader/uncommitted/uncommittedMode.ts b/vscode/src/codeReader/uncommitted/uncommittedMode.ts index 0752074..5107fae 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedMode.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedMode.ts @@ -1,15 +1,18 @@ -import type { ExtensionContext, TreeView, WorkspaceFolder } from 'vscode' +import type { ExtensionContext, TreeView, Uri, WorkspaceFolder } from 'vscode' import type { GitApi, GitRepository } from '../../git/gitApi' +import type { DiffBase } from '../diff/diffBase' import type { ChangedFile } from '../explorer/changedTree' import type { ChangedSection, FileNode, FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' import type { UncommittedRoot } from './uncommittedFiles' import * as path from 'node:path' import { commands, Disposable, window, workspace } from 'vscode' +import { fileAtRef } from '../../git/fileAt' import { loadGitApi } from '../../git/gitExtension' import { logger } from '../../logging/logger' import { buildChangedTree } from '../explorer/changedTree' -import { uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles' +import { toPathKey } from '../pathMap' +import { HEAD_BASE_LABEL, headMissingText, isUntracked, uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles' /** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */ const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted' @@ -33,7 +36,7 @@ function errorText(err: unknown): string { /** * 侧栏只看未提交的更改:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。 * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。 - * 可以隐藏测试文件。开着时盯着 git 状态自动重摆;「显示全部」回到工作区文件树。 + * 可以隐藏测试文件。开着时盯着 git 状态自动重摆,打开的预览对比上一次提交(diffBase);「显示全部」回到工作区文件树。 * 开着没开、隐不隐藏测试文件都按工作区记住;和审查工单互斥,见 TreeFilters。 */ export class UncommittedMode implements TreeFilter, Disposable { @@ -123,6 +126,24 @@ export class UncommittedMode implements TreeFilter, Disposable { } } + /** 开着时,工作区仓库里的文件对比上一次提交(HEAD);不在工作区的仓库里(审查的工作副本、别处的文件)的不比。 */ + diffBase(uri: Uri): DiffBase | undefined { + const repository = this.api?.getRepository(uri) + if (!repository || workspaceOrder(repository.rootUri.path, workspace.workspaceFolders ?? []) < 0) + return undefined + const relative = toPathKey(repository.rootUri.path, uri.path) + if (relative === undefined) + return undefined + const head = repository.state.HEAD?.commit + const root = repository.rootUri.fsPath + return { + label: HEAD_BASE_LABEL, + key: `${head ?? ''}:${relative}`, + read: () => head ? fileAtRef(root, head, relative) : Promise.resolve(null), + missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(repository.state, uri.path) }), + } + } + dispose(): void { this.stopWatching() for (const disposable of this.disposables) @@ -173,12 +194,18 @@ export class UncommittedMode implements TreeFilter, Disposable { } } - /** 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆。 */ + /** + * 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆; + * 打开着的预览也跟着重新比(提交后 HEAD 动了,对比的上一次提交就换了)。 + */ private watch(api: GitApi): Disposable { const repositories = new Map() const schedule = (): void => { clearTimeout(this.timer) - this.timer = setTimeout(() => this.rebuild(), REBUILD_DELAY_MS) + this.timer = setTimeout(() => { + this.rebuild() + this.filters.baseChanged(this) + }, REBUILD_DELAY_MS) } const add = (repository: GitRepository): void => { if (!repositories.has(repository)) diff --git a/vscode/src/git/fileAt.test.ts b/vscode/src/git/fileAt.test.ts new file mode 100644 index 0000000..48f989d --- /dev/null +++ b/vscode/src/git/fileAt.test.ts @@ -0,0 +1,39 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { fileAtRef } from './fileAt' +import { runGit } from './worktree' + +describe('fileAtRef', () => { + let repo: string + let head: string + + beforeAll(async () => { + repo = mkdtempSync(path.join(os.tmpdir(), 'file-at-')) + await runGit(repo, ['init', '-q']) + mkdirSync(path.join(repo, 'src')) + writeFileSync(path.join(repo, 'src', 'a.py'), 'x = 1\n') + await runGit(repo, ['add', '.']) + await runGit(repo, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init']) + head = (await runGit(repo, ['rev-parse', 'HEAD'])).trim() + writeFileSync(path.join(repo, 'src', 'a.py'), 'x = 2\n') + writeFileSync(path.join(repo, 'src', 'b.py'), 'y = 1\n') + }) + + afterAll(() => { + rmSync(repo, { recursive: true, force: true }) + }) + + it('取提交里的内容,不是工作区里改过的', async () => { + expect(await fileAtRef(repo, head, 'src/a.py')).toBe('x = 1\n') + }) + + it('提交里没有这个文件是 null', async () => { + expect(await fileAtRef(repo, head, 'src/b.py')).toBeNull() + }) + + it('提交不存在时抛错', async () => { + await expect(fileAtRef(repo, '0123456789abcdef0123456789abcdef01234567', 'src/a.py')).rejects.toThrow() + }) +}) diff --git a/vscode/src/git/fileAt.ts b/vscode/src/git/fileAt.ts new file mode 100644 index 0000000..de9febb --- /dev/null +++ b/vscode/src/git/fileAt.ts @@ -0,0 +1,12 @@ +import { runGit } from './worktree' + +/** + * 仓库里 ref 这个提交上 path 这个文件的内容(path 相对仓库根、`/` 分隔);那个提交里没有这个文件时是 null。 + * 先 ls-tree 看有没有、再 show 取内容,这样「没有这个文件」和 git 出错分得开(出错照常抛)。 + */ +export async function fileAtRef(repoRoot: string, ref: string, path: string): Promise { + const listed = await runGit(repoRoot, ['ls-tree', '--name-only', ref, '--', path], `查 ${ref} 上有没有 ${path}`) + if (!listed.trim()) + return null + return runGit(repoRoot, ['show', `${ref}:${path}`], `读 ${ref} 上的 ${path}`) +} diff --git a/vscode/src/git/gitApi.ts b/vscode/src/git/gitApi.ts index eb0534d..1f3ef11 100644 --- a/vscode/src/git/gitApi.ts +++ b/vscode/src/git/gitApi.ts @@ -36,7 +36,14 @@ export interface GitChange { readonly status: GitStatus } +/** 当前检出的分支或提交;commit 是它指向的提交,仓库还没有提交时没有。 */ +export interface GitHead { + readonly commit?: string +} + export interface GitRepositoryState { + /** 仓库还没打开完时没有 */ + readonly HEAD: GitHead | undefined /** 合并冲突的 */ readonly mergeChanges: readonly GitChange[] /** 暂存的 */ diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 90ed0d2..7123039 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -324,6 +324,8 @@ export interface GiteaPullRequest { headRef: string /** base 分支 sha:mergeBase 取不到时的左侧 ref 兜底。 */ baseSha: string + /** base 分支名(PR 要合进去的目标分支),如 `master`。 */ + baseRef: string /** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */ mergeBase: string } @@ -353,7 +355,7 @@ export async function getPullRequest(opts: { html_url?: unknown body?: unknown head?: { sha?: unknown, ref?: unknown } - base?: { sha?: unknown } + base?: { sha?: unknown, ref?: unknown } merge_base?: unknown } return { @@ -366,6 +368,7 @@ export async function getPullRequest(opts: { headSha: typeof data.head?.sha === 'string' ? data.head.sha : '', headRef: typeof data.head?.ref === 'string' ? data.head.ref : '', baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '', + baseRef: typeof data.base?.ref === 'string' ? data.base.ref : '', mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '', } }