✨ feat(vscode): 代码阅读预览问 AI 的回答里代码位置写成链接、点了能跳:提示词要求写 [名字](路径#符号) 或 [文件名:起-止](路径#L起-L止) 并给出仓库根,仓库链接认 #L 行号(注释里同一套),旧回答里整段是「路径:行」的行内代码也当链接,带行号的在预览里展开、闪包住那几行的块,不支持预览的用文本编辑器选中

This commit is contained in:
cruldra committed 2026-10-14 06:30:30 +11:00
1 parent ceeaad91a7
commit e401e4aec3
22 files changed
+516 -90

No files matched your search

+11 -2
View File
@@ -33,10 +33,18 @@ export interface SymbolReference {
/**
* 让预览滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
* 预览展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下。
* endLine 只在跳到几行(line 到 endLine)时有:预览展开、闪包住这几行的那块。都没有是从文本编辑器切过来,只滚不闪。
*/
export interface Reveal {
line: number
definition?: number
endLine?: number
}
/** 文件里的几行:从 start 行到 end 行,1 起、两头都算,同链接里的 `#L起-L止`。 */
export interface LineRange {
start: number
end: number
}
/**
@@ -267,7 +275,8 @@ export type ExtensionToWebview
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
* 扩展端按它设 when 子句用的上下文键
* findNavigation:眼下查找栏的快捷键归不归这个页面(查找栏开着、页面有焦点),变了才发;扩展端按它设 when 子句用的上下文键
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,lines 是文件里的几行(和 symbol 二选一),
* 都没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference),
* 从那块的声明起一层层往外找本文件定义的,再是导入的(见 symbolLinks.ts)
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问
@@ -297,7 +306,7 @@ export type WebviewToExtension
| { type: 'selectProfile', kind: ChatKind, id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'findNavigation', available: boolean }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string, lines?: LineRange }
| { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
| { type: 'openCallTree', version: number, anchor: string }
@@ -1,14 +1,22 @@
import type { Range, Uri } from 'vscode'
import type { Uri } from 'vscode'
import { describe, expect, it, vi } from 'vitest'
import { workspace } from 'vscode'
import { languageOf, openingFor } from './openTarget'
import { Range, workspace } from 'vscode'
import { languageOf, linesSpot, openingFor } from './openTarget'
vi.mock('vscode', () => ({
workspace: { openTextDocument: vi.fn() },
Range: class {
readonly start: { line: number, character: number }
readonly end: { line: number, character: number }
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) {
this.start = { line: startLine, character: startCharacter }
this.end = { line: endLine, character: endCharacter }
}
},
}))
const supports = (languageId: string): boolean => languageId === 'python'
const range = { start: { line: 41, character: 6 }, end: { line: 41, character: 28 } } as Range
const range = new Range(41, 6, 41, 28)
const spot = { range, offset: 1200 }
describe('打开别的文件用哪种编辑器', () => {
@@ -27,6 +35,37 @@ describe('打开别的文件用哪种编辑器', () => {
it('打不开成文本(图片):VS Code 默认的打开方式', () => {
expect(openingFor(undefined, supports)).toEqual({ kind: 'default' })
})
it('跳到几行(没有定义的下标):预览滚到包住这几行的块;普通编辑器选中这几行', () => {
const lines = new Range(356, 0, 363, 9)
expect(openingFor('python', supports, { range: lines })).toEqual({ kind: 'preview', reveal: { line: 356, endLine: 363 } })
expect(openingFor('c', supports, { range: lines })).toEqual({ kind: 'default', selection: lines })
})
})
describe('文件里的几行', () => {
const uri = {} as Uri
/** 一份 10 行的文件,第 n 行(0 起)有 n 个字 */
const document = { lineCount: 10, lineAt: (line: number) => ({ text: 'x'.repeat(line) }) }
it('从第一行行首选到最后一行行尾(链接里 1 起,编辑器里 0 起);超出文件的钳到最后一行', async () => {
const openTextDocument = vi.mocked(workspace.openTextDocument)
openTextDocument.mockResolvedValue(document as Awaited<ReturnType<typeof workspace.openTextDocument>>)
expect(await linesSpot(uri, { start: 3, end: 5 })).toEqual({ range: new Range(2, 0, 4, 4) })
expect(await linesSpot(uri, { start: 7, end: 7 })).toEqual({ range: new Range(6, 0, 6, 6) })
expect(await linesSpot(uri, { start: 9, end: 40 })).toEqual({ range: new Range(8, 0, 9, 9) })
expect(await linesSpot(uri, { start: 50, end: 60 })).toEqual({ range: new Range(9, 0, 9, 9) })
})
it('行号不对(不是从 1 起的整数、止在起前面)、文件打不开成文本时没有', async () => {
const openTextDocument = vi.mocked(workspace.openTextDocument)
openTextDocument.mockResolvedValue(document as Awaited<ReturnType<typeof workspace.openTextDocument>>)
expect(await linesSpot(uri, { start: 0, end: 3 })).toBeUndefined()
expect(await linesSpot(uri, { start: 5, end: 3 })).toBeUndefined()
expect(await linesSpot(uri, { start: 1.5, end: 3 })).toBeUndefined()
openTextDocument.mockRejectedValueOnce(new Error('binary'))
expect(await linesSpot(uri, { start: 1, end: 2 })).toBeUndefined()
})
})
describe('文件的语言', () => {
+34 -6
View File
@@ -1,11 +1,14 @@
import type { Range, Uri } from 'vscode'
import type { Reveal } from './messages'
import { workspace } from 'vscode'
import type { Uri } from 'vscode'
import type { LineRange, Reveal } from './messages'
import { Range, workspace } from 'vscode'
/** 别的文件里要打开的一处:名字的范围(普通编辑器选中它)和起点下标(预览滚过去、闪这处定义)。 */
/**
* 别的文件里要打开的一处:普通编辑器选中 `range`;预览按 `offset`(定义处的下标)滚过去、闪这处定义。
* 没有 `offset` 的是几行(`range` 是这几行,见 [linesSpot]),预览闪包住它们的那块。
*/
export interface TargetSpot {
range: Range
offset: number
offset?: number
}
/**
@@ -22,10 +25,35 @@ export type Opening
*/
export function openingFor(languageId: string | undefined, supports: (languageId: string) => boolean, spot?: TargetSpot): Opening {
if (languageId !== undefined && supports(languageId))
return { kind: 'preview', ...(spot && { reveal: { line: spot.range.start.line, definition: spot.offset } }) }
return { kind: 'preview', ...(spot && { reveal: revealOf(spot) }) }
return { kind: 'default', ...(spot && { selection: spot.range }) }
}
/** 预览怎么滚到这一处:有 `offset` 的跳到定义,没有的跳到 `range` 那几行。 */
export function revealOf({ range, offset }: TargetSpot): Reveal {
return offset === undefined ? { line: range.start.line, endLine: range.end.line } : { line: range.start.line, definition: offset }
}
/**
* 文件里的几行(链接里的 `#L起-L止`,1 起)要打开的那一处:从第一行行首选到最后一行行尾,超出文件的钳到最后一行。
* 行号不对(不是从 1 起的整数、止在起前面)、文件打不开成文本时没有。
*/
export async function linesSpot(uri: Uri, { start, end }: LineRange): Promise<TargetSpot | undefined> {
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start)
return undefined
let document
try {
document = await workspace.openTextDocument(uri)
}
catch {
return undefined
}
const last = document.lineCount - 1
const first = Math.min(start - 1, last)
const final = Math.min(end - 1, last)
return { range: new Range(first, 0, final, document.lineAt(final).text.length) }
}
/** 按 VS Code 的判断(含 files.associations)取文件的 languageId;图片等二进制文件打不开成文本,返回 undefined。 */
export async function languageOf(uri: Uri): Promise<string | undefined> {
try {
@@ -30,7 +30,10 @@ vi.mock('vscode', () => ({
},
Range: class {
constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {}
get start() { return { line: this.startLine, character: this.startCharacter } }
get end() { return { line: this.endLine, character: this.endCharacter } }
},
Uri: { file: (fsPath: string) => ({ fsPath, scheme: 'file', toString: () => `file://${fsPath}` }) },
EndOfLine: { LF: 1, CRLF: 2 },
}))
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
@@ -52,6 +55,12 @@ vi.mock('./documentMessage', async importOriginal => ({
}))
/** 文档符号由各个测试给 */
vi.mock('./documentSymbols', () => ({ documentSymbolNodes: vi.fn(async () => []) }))
/** 仓库在 /workspace,里面只有这几个文件 */
vi.mock('./repoPath', async importOriginal => ({
...await importOriginal<typeof import('./repoPath')>(),
repoRootOf: async () => '/workspace',
resolveRepoEntry: async (_root: string, rel: string) => ['web/a.ts', 'web/b.ts', 'docs/notes.txt'].includes(rel) ? { fsPath: `/workspace/${rel}`, isDirectory: false } : undefined,
}))
const SOURCE = ['// 旧说明', 'export function load() {}']
@@ -189,7 +198,7 @@ function themeColors(colors: CodeColors) {
}
/** 对比基准只认从「未提交的更改」点开的:从那儿点开的对比上一次提交,别处打开的不比 */
function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, colors = themeColors(COLORS)) {
function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, colors = themeColors(COLORS), languageFor: (languageId: string) => unknown = () => undefined) {
const subscribe = () => ({ dispose: () => {} })
const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined)
const qa = {
@@ -202,7 +211,7 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, co
}
const provider = new PreviewEditorProvider(...[
{},
{ selectedTranslator: () => undefined },
{ selectedTranslator: () => undefined, languageFor },
{},
{ subscribe, current: { excluded: {}, onlyChanges: false } },
colors,
@@ -565,6 +574,60 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () =
})
})
describe('点了带几行的仓库链接', () => {
/** 文件按名字认语言,第 n 行(0 起)有 n 个字,一共 20 行 */
function textDocument(fsPath: string) {
const languageId = fsPath.endsWith('.ts') ? 'typescript' : 'plaintext'
return { uri: { fsPath, scheme: 'file', toString: () => `file://${fsPath}` }, languageId, lineCount: 20, lineAt: (line: number) => ({ text: 'x'.repeat(line) }) }
}
async function linked() {
vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { fsPath: string }) => textDocument(uri.fsPath)) as never)
vi.mocked(commands.executeCommand).mockClear()
const { provider, document } = opening(undefined, undefined, languageId => languageId === 'typescript' ? {} : undefined)
const page = tab()
provider.resolveCustomTextEditor(document as never, page.panel)
await page.send({ type: 'ready' })
await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document' })))
page.clear()
return { provider, page }
}
it('指到这份文件:就在本预览里滚到包住这几行的块', async () => {
const { page } = await linked()
await page.send({ type: 'openRepoLink', path: 'web/a.ts', lines: { start: 2, end: 3 } })
expect(page.posted()).toEqual([{ type: 'reveal', line: 1, endLine: 2 }, { type: 'repoLinkOpened', path: 'web/a.ts' }])
})
it('指到别的有代码阅读的文件:用预览打开,滚到包住这几行的块', async () => {
const { provider, page } = await linked()
const open = vi.spyOn(provider, 'open').mockResolvedValue()
await page.send({ type: 'openRepoLink', path: 'web/b.ts', lines: { start: 5, end: 9 } })
expect(open).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/b.ts' }), { reveal: { line: 4, endLine: 8 } }, expect.any(Function))
expect(page.posted()).toEqual([{ type: 'repoLinkOpened', path: 'web/b.ts' }])
})
it('指到没有代码阅读的文件:文本编辑器打开,选中这几行', async () => {
const { page } = await linked()
await page.send({ type: 'openRepoLink', path: 'docs/notes.txt', lines: { start: 3, end: 4 } })
expect(commands.executeCommand).toHaveBeenCalledWith('vscode.open', expect.objectContaining({ fsPath: '/workspace/docs/notes.txt' }), expect.objectContaining({ selection: expect.objectContaining({ startLine: 2, startCharacter: 0, endLine: 3, endCharacter: 3 }) }))
expect(page.posted()).toEqual([{ type: 'repoLinkOpened', path: 'docs/notes.txt' }])
})
it('文件没有:标坏,不打开', async () => {
const { page } = await linked()
await page.send({ type: 'openRepoLink', path: 'web/gone.ts', lines: { start: 1, end: 1 } })
expect(page.posted()).toEqual([{ type: 'repoLinkBroken', path: 'web/gone.ts' }])
expect(commands.executeCommand).not.toHaveBeenCalledWith('vscode.open', expect.anything(), expect.anything())
})
it('问哪些路径没有时,带几行的链接按文件问', async () => {
const { page } = await linked()
await page.send({ type: 'checkRepoLinks', id: 3, paths: ['web/a.ts', 'web/gone.ts'], references: [] })
expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 3, missing: ['web/gone.ts'], unresolved: [] }])
})
})
describe('预览里的查找栏', () => {
const sent = (page: ReturnType<typeof tab>): ExtensionToWebview[] => page.posted().filter(msg => msg.type === 'openFind' || msg.type === 'moveFind')
+21 -13
View File
@@ -15,7 +15,7 @@ import type { ThemeColors } from '../theme/codeColors'
import type { IndexFind } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { FoundLocation } from './definitions'
import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages'
import type { ChatKind, DefinitionPeek, ExtensionToWebview, LineRange, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import type { TopSubject } from './pathPreview'
import { realpath } from 'node:fs/promises'
@@ -47,7 +47,7 @@ import { documentSymbolNodes } from './documentSymbols'
import { FIND_CONTEXT } from './findCommands'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { languageOf, openingFor } from './openTarget'
import { languageOf, linesSpot, openingFor, revealOf } from './openTarget'
import { PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
@@ -650,7 +650,8 @@ class Preview {
return
}
const at = locateQa(subject.uri)
const block = at && this.sent && { path: at.promptPath, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor }
const repoRoot = await repoRootOf(this.document.uri.fsPath)
const block = at && this.sent && { path: at.promptPath, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor, ...(repoRoot !== undefined && { repoRoot }) }
const prompt = block && askPrompt(block, question)
const followUp = block && followUpPrompt(block, question)
const refused = prompt === undefined || followUp === undefined
@@ -754,7 +755,7 @@ class Preview {
await this.findOccurrences(msg.id, msg.version, msg.offset)
return
case 'openRepoLink':
await this.openRepoLink(msg.path, msg.symbol, msg.block)
await this.openRepoLink(msg.path, msg.symbol, msg.block, msg.lines)
return
case 'checkRepoLinks':
await this.checkRepoLinks(msg.id, msg.paths, msg.references)
@@ -985,10 +986,11 @@ class Preview {
}
/**
* 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。没有路径的是不带路径的引用,按这份文件、从它写在的那块(block)找(见 resolveSymbolLink)。
* 打开注释、问 AI 的回答里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去,带了几行(`lines`)就跳到这几行(见 [linesSpot]);文件或符号没有就不打开。
* 没有路径的是不带路径的引用,按这份文件、从它写在的那块(`block`)找(见 [resolveSymbolLink])。
*/
private async openRepoLink(rel: string | undefined, symbol?: string, block?: string): Promise<void> {
private async openRepoLink(rel: string | undefined, symbol?: string, block?: string, lines?: LineRange): Promise<void> {
const link = rel ?? `[${symbol}]`
try {
if (symbol !== undefined) {
@@ -1016,7 +1018,8 @@ class Preview {
this.markRepoLinkOpened(rel)
return
}
await this.openElsewhere(uri)
const spot = lines && await linesSpot(uri, lines)
await (spot ? this.showSpot(uri, spot) : this.openElsewhere(uri))
this.markRepoLinkOpened(rel)
}
catch (err) {
@@ -1071,14 +1074,19 @@ class Preview {
}
}
/** 跳到链接指的符号:在这份文件里就在本预览里滚过去、闪一下;别的文件同 ⌘ + 点击跳到定义(见 openElsewhere)。 */
/** 跳到链接指的符号:名字那段是要选中的范围,起点下标是预览要闪的定义处(见 [showSpot])。 */
private async showLinkTarget({ uri, selection }: LinkTarget): Promise<void> {
if (uri === this.document.uri.toString()) {
this.reveal({ line: this.document.positionAt(selection.startIndex).line, definition: selection.startIndex })
const doc = uri === this.document.uri.toString() ? this.document : await workspace.openTextDocument(Uri.parse(uri))
await this.showSpot(doc.uri, { range: new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)), offset: selection.startIndex })
}
/** 跳到链接指的一处(符号或几行):在这份文件里就在本预览里滚过去、闪一下;别的文件同 ⌘ + 点击跳到定义(见 [openElsewhere])。 */
private async showSpot(uri: Uri, spot: TargetSpot): Promise<void> {
if (uri.toString() === this.document.uri.toString()) {
this.reveal(revealOf(spot))
return
}
const doc = await workspace.openTextDocument(Uri.parse(uri))
await this.openElsewhere(doc.uri, { range: new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)), offset: selection.startIndex })
await this.openElsewhere(uri, spot)
}
private markRepoLinkBroken(path: string | undefined, symbol?: string, block?: string): void {
+1 -1
View File
@@ -344,7 +344,7 @@ export class CodeQa implements Disposable {
if (!at)
return NO_QA_HOME
const described = await describePath({ uri, isDirectory: kind === 'folder', at })
const run = { prompt: pathAskPrompt(described, request.question), followUp: pathAskFollowUp(described.path, kind, request.question), ...outsideDirs(at) }
const run = { prompt: pathAskPrompt(described, request.question), followUp: pathAskFollowUp(described, request.question), ...outsideDirs(at) }
return this.askTop(uri, request, run, async answer => ({ text: answer.text }))
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 问 AI 时要模型怎么写回答里的代码位置:写成和注释里一样的仓库链接,代码阅读预览里点了能跳过去。
*/
/** 链接打不开时(问的代码不在 git 仓库里)的写法:行内代码的绝对路径加行号。 */
const OUTSIDE_RULE = '问的代码不在 git 仓库里,提到代码位置时写行内代码:绝对路径加行号,如 `/path/to/file.py:12-20`。'
/**
* 要模型把代码位置写成 Markdown 链接的那段话,带一个例子。链接的写法同注释里的仓库链接([parseRepoLink](vscode/webview-ui/src/codeReader/repoLink.ts#parseRepoLink) 认的):
* 优先 `#符号`,几行写 `#L起-L止`。预览按被问的代码所在的 git 仓库根找链接里的路径,所以把这个根告诉模型。
*
* @param repoRoot 被问的代码所在的 git 仓库根(绝对路径);不在 git 仓库里时没有,链接打不开,改要行内代码的绝对路径
*/
export function codeLinkRule(repoRoot: string | undefined): string {
if (repoRoot === undefined)
return OUTSIDE_RULE
return [
`提到代码位置时写成 Markdown 链接,点了能跳过去。路径从仓库根 ${repoRoot} 算起,开头不带 / 或 ./:`,
'- 指到函数、类、方法:[显示名](路径#符号),符号写函数名或「类名.方法名」。优先用这种,代码改了行号也不失效。',
'- 指到函数里的几行:[文件名:起-止](路径#L起-L止),只有一行时写 #L行号。',
'例如:[OrderService.cancel](backend/app/orders/service.py#OrderService.cancel)、[service.py:357-364](backend/app/orders/service.py#L357-L364)。',
'仓库外的代码(比如依赖库)不写链接,写行内代码:绝对路径加行号,如 `/path/to/file.py:12-20`。',
].join('\n')
}
+4 -2
View File
@@ -1,5 +1,5 @@
/**
* 顶部问答带给模型的材料:文件是路径加开头若干行,文件夹是路径加这一层的名字。
* 顶部问答带给模型的材料:文件是路径加开头若干行,文件夹是路径加这一层的名字,还有它所在的 git 仓库根(回答里的链接从这里算路径)。
* 路径见 [QaLocation.promptPath]:工作区里的是相对路径,工作区外的库文件是绝对路径。
*/
@@ -8,13 +8,15 @@ import type { ExcludeSetting } from '../explorer/fileEntries'
import type { QaLocation } from '../folderStore'
import type { PathAskSubject, PathChild } from './pathAskPrompt'
import { FileType, workspace } from 'vscode'
import { repoRootOf } from '../editor/repoPath'
import { createExcludeMatcher, sortEntries } from '../explorer/fileEntries'
import { readFileHead } from './pathAskContext'
import { displayRelativePath } from './pathQa'
export async function describePath(target: { uri: Uri, isDirectory: boolean, at: QaLocation }): Promise<PathAskSubject> {
const { uri, isDirectory, at } = target
const where = { path: displayRelativePath(at.promptPath), ...(at.outsideDir !== undefined && { outside: true as const }) }
const repoRoot = await repoRootOf(uri.fsPath)
const where = { path: displayRelativePath(at.promptPath), ...(at.outsideDir !== undefined && { outside: true as const }), ...(repoRoot !== undefined && { repoRoot }) }
if (isDirectory)
return { kind: 'folder', ...where, children: await folderChildren(uri, at.folder, at.key) }
return { kind: 'file', ...where, head: await readFileHead(uri.fsPath) }
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { headLines } from './pathAskContext'
import { PATH_ASK_HEAD_LINES, pathAskPrompt } from './pathAskPrompt'
import { PATH_ASK_HEAD_LINES, pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
const FILE = 'app/[[...section]]/page.tsx'
const FOLDER = 'app/[[...section]]'
@@ -57,6 +57,26 @@ describe('问路径时发给模型的话', () => {
})
})
describe('问路径时回答里的代码位置', () => {
it('在 git 仓库里:要求写成从仓库根算起的链接,带例子;接着问时再说一遍', () => {
const subject = { kind: 'folder' as const, path: FOLDER, repoRoot: '/work/shop', children: [] }
const prompt = pathAskPrompt(subject, '这里放什么')
expect(prompt).toContain('提到代码位置时写成 Markdown 链接,点了能跳过去。路径从仓库根 /work/shop 算起,开头不带 / 或 ./:')
expect(prompt).toContain('[文件名:起-止](路径#L起-L止)')
expect(prompt).toContain('例如:[OrderService.cancel](backend/app/orders/service.py#OrderService.cancel)')
expect(prompt).not.toContain('提到文件时写相对项目根目录的路径')
const followUp = pathAskFollowUp(subject, '再说说')
expect(followUp).toMatch(/^接着问(还是 app\/\[\[\.\.\.section\]\] 这个文件夹,需要时重新读):再说说\n\n提到代码位置时写成 Markdown 链接/)
})
it('不在 git 仓库里(库源码):写行内代码的绝对路径,不写链接', () => {
const library = '/Users/me/.local/share/uv/python/cpython-3.12/lib/python3.12/pathlib.py'
const prompt = pathAskPrompt({ kind: 'file', path: library, outside: true, head: { lines: [], truncated: false } }, '?')
expect(prompt).toContain('问的代码不在 git 仓库里,提到代码位置时写行内代码:绝对路径加行号')
expect(prompt).not.toContain('Markdown 链接')
})
})
describe('文件开头截取', () => {
it('超过行数上限时只留前 80 行,并标明没贴完', () => {
const text = Array.from({ length: 100 }, (_, index) => `L${index}`).join('\n')
+11 -7
View File
@@ -1,8 +1,10 @@
/**
* 侧栏问一个路径时发给模型的话:写明是文件还是文件夹、相对工作区的路径(工作区外的库文件写绝对路径)。
* 文件附上开头若干行;文件夹只列这一层的名字,不把里面的代码贴进去。
* 文件附上开头若干行;文件夹只列这一层的名字,不把里面的代码贴进去。回答里的代码位置怎么写见 [codeLinkRule]。
*/
import { codeLinkRule } from './linkRule'
/** 文件开头最多贴这么多行,剩下的让模型自己去读。 */
export const PATH_ASK_HEAD_LINES = 80
@@ -20,6 +22,8 @@ export type PathHead
interface PathAskWhere {
path: string
outside?: true
/** 这条路径所在的 git 仓库根,回答里的链接从这里算路径;不在 git 仓库里时没有 */
repoRoot?: string
}
export interface PathAskFile extends PathAskWhere {
@@ -35,7 +39,7 @@ export interface PathAskFolder extends PathAskWhere {
export type PathAskSubject = PathAskFile | PathAskFolder
const LEAD = '我在看项目里的一个路径,想问它。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的文件,但只读,不要改任何东西。提到文件时写相对项目根目录的路径。'
const LEAD = '我在看项目里的一个路径,想问它。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的文件,但只读,不要改任何东西。'
/** 一次提问的全文。不写进某一块代码的问答记录。 */
export function pathAskPrompt(subject: PathAskSubject, question: string): string {
@@ -44,13 +48,13 @@ export function pathAskPrompt(subject: PathAskSubject, question: string): string
? `这是项目外的一个${noun}(比如依赖库的源码),绝对路径:${subject.path}`
: `这是一个${noun},相对路径:${subject.path}`
const detail = subject.kind === 'file' ? fileHead(subject.head) : folderList(subject.children)
return [LEAD, where, detail, `问题:${question}`].join('\n\n')
return [LEAD, codeLinkRule(subject.repoRoot), where, detail, `问题:${question}`].join('\n\n')
}
/** 这条路径已经问过、接着上一次会话问时发的话:只说还是这个文件或文件夹(`path` 同 [PathAskSubject] 的),材料在会话里。 */
export function pathAskFollowUp(path: string, kind: 'file' | 'folder', question: string): string {
const noun = kind === 'folder' ? '文件夹' : '文件'
return `接着问(还是 ${path} 这个${noun},需要时重新读):${question}`
/** 这条路径已经问过、接着上一次会话问时发的话:只说还是这个文件或文件夹,材料在会话里;代码位置怎么写再说一遍(以前开的会话里没说过)。 */
export function pathAskFollowUp(subject: PathAskSubject, question: string): string {
const noun = subject.kind === 'folder' ? '文件夹' : '文件'
return `接着问(还是 ${subject.path} 这个${noun},需要时重新读):${question}\n\n${codeLinkRule(subject.repoRoot)}`
}
function fileHead(head: PathHead): string {
+23 -5
View File
@@ -22,15 +22,25 @@ class ReferralRelation(Base):
async function subject(source: string, pick: (views: BlockView[]) => BlockView): Promise<AskSubject> {
const blocks = await withBlocks(py, 'python', source, list => toBlockViews(list, { source, language: py, markers: [] }, undefined))
return { path: 'app/models.py', languageId: 'python', source, blocks, anchor: pick(blocks).anchor }
return { path: 'app/models.py', languageId: 'python', source, blocks, anchor: pick(blocks).anchor, repoRoot: '/work/shop' }
}
/** 要回答把代码位置写成仓库链接的那段话:仓库根是 /work/shop */
const LINK_RULE = [
'提到代码位置时写成 Markdown 链接,点了能跳过去。路径从仓库根 /work/shop 算起,开头不带 / 或 ./:',
'- 指到函数、类、方法:[显示名](路径#符号),符号写函数名或「类名.方法名」。优先用这种,代码改了行号也不失效。',
'- 指到函数里的几行:[文件名:起-止](路径#L起-L止),只有一行时写 #L行号。',
'例如:[OrderService.cancel](backend/app/orders/service.py#OrderService.cancel)、[service.py:357-364](backend/app/orders/service.py#L357-L364)。',
'仓库外的代码(比如依赖库)不写链接,写行内代码:绝对路径加行号,如 `/path/to/file.py:12-20`。',
].join('\n')
describe('问 AI 的话', () => {
it('第一次问:要求中文大白话、只读、写相对路径;带文件、语言、外层块的开头和这块代码(带行号)、问题', async () => {
it('第一次问:要求中文大白话、只读、代码位置写成仓库链接;带文件、语言、外层块的开头和这块代码(带行号)、问题', async () => {
const asked = await subject(SOURCE, blocks => blocks[1].children[0].children[0])
expect(asked.anchor).toMatch(/^class ReferralRelation > function display > if @/)
expect(askPrompt(asked, '为什么要判断 active?')).toBe([
'我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。提到文件时写相对项目根目录的路径,最好带行号。',
'我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。',
LINK_RULE,
'文件:app/models.py(python)',
'这块代码外面套着(各层外层块的开头):\n4 | class ReferralRelation(Base):\n5 | """推荐关系。"""\n7 | def display(self):',
'我问的是这一块(第 8–9 行):\n8 | if self.active:\n9 | return self.name',
@@ -67,8 +77,16 @@ describe('问 AI 的话', () => {
expect(findBlock(asked.blocks, 'function gone')).toBeUndefined()
})
it('追问只提醒是哪块', async () => {
it('追问只提醒是哪块,再带上代码位置怎么写(以前的会话里可能没说过)', async () => {
const asked = await subject(SOURCE, blocks => blocks[1].children[0])
expect(followUpPrompt(asked, '那 name 从哪来?')).toBe('接着问(还是 app/models.py 第 7–10 行那块代码,代码可能刚改过,需要时重新读文件):那 name 从哪来?')
expect(followUpPrompt(asked, '那 name 从哪来?')).toBe(`接着问(还是 app/models.py 第 7–10 行那块代码,代码可能刚改过,需要时重新读文件):那 name 从哪来?\n\n${LINK_RULE}`)
})
it('文件不在 git 仓库里(没有仓库根):链接打不开,代码位置写成行内代码的绝对路径', async () => {
const { repoRoot: _, ...outside } = await subject(SOURCE, blocks => blocks[0])
const rule = '问的代码不在 git 仓库里,提到代码位置时写行内代码:绝对路径加行号,如 `/path/to/file.py:12-20`。'
expect(askPrompt(outside, '?')).toContain(`不要改任何东西。\n\n${rule}\n\n文件:`)
expect(askPrompt(outside, '?')).not.toContain('Markdown 链接')
expect(followUpPrompt(outside, '?')?.endsWith(`:?\n\n${rule}`)).toBe(true)
})
})
+8 -5
View File
@@ -1,10 +1,11 @@
/**
* 问 AI 时发给 Claude Code 的话:文件、语言、这块代码(带行号)、外面套着的各层块的开头、问题。
* 问 AI 时发给 Claude Code 的话:文件、语言、这块代码(带行号)、外面套着的各层块的开头、问题,以及回答里的代码位置怎么写。
* 只用和语言无关的块数据(BlockView 的行号、记号、树形),不认识任何语言。
*/
import type { BlockView } from '../blockView'
import { splitLines } from '../editText'
import { codeLinkRule } from './linkRule'
/** 这块代码最多贴多少行,再长的只贴开头、告诉它剩下的在哪几行 */
const BLOCK_LINES = 120
@@ -18,6 +19,8 @@ export interface AskSubject {
source: string
blocks: BlockView[]
anchor: string
/** 这份文件所在的 git 仓库根,回答里的链接从这里算路径(见 [codeLinkRule]);不在 git 仓库里时没有 */
repoRoot?: string
}
/** anchor 那块,和从最外层到它的各层外层块;树里没有这块时没有。 */
@@ -53,8 +56,8 @@ export function askPrompt(subject: AskSubject, question: string): string | undef
const shownLast = Math.min(last, block.startLine + BLOCK_LINES - 1)
const { from, to } = blockLines(block)
const parts = [
'我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。'
+ '提到文件时写相对项目根目录的路径,最好带行号。',
'我在读代码,想问一块代码。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的其他文件,但只读,不要改任何东西。',
codeLinkRule(subject.repoRoot),
`文件:${subject.path}(${subject.languageId})`,
]
if (outer.length > 0) {
@@ -71,11 +74,11 @@ export function askPrompt(subject: AskSubject, question: string): string | undef
return parts.join('\n\n')
}
/** 接着上一次的会话追问:代码上下文会话里已经有了,只提醒是哪块。 */
/** 接着上一次的会话追问:代码上下文会话里已经有了,只提醒是哪块;代码位置怎么写再说一遍(以前开的会话里没说过)。 */
export function followUpPrompt(subject: AskSubject, question: string): string | undefined {
const found = findBlock(subject.blocks, subject.anchor)
if (!found)
return undefined
const { from, to } = blockLines(found.block)
return `接着问(还是 ${subject.path} 第 ${from}–${to} 行那块代码,代码可能刚改过,需要时重新读文件):${question}`
return `接着问(还是 ${subject.path} 第 ${from}–${to} 行那块代码,代码可能刚改过,需要时重新读文件):${question}\n\n${codeLinkRule(subject.repoRoot)}`
}
+1 -1
View File
@@ -200,7 +200,7 @@ export function App(): ReactElement | null {
}))
return
case 'reveal':
page.update({ reveal: { line: msg.line, definition: msg.definition } })
page.update({ reveal: { line: msg.line, definition: msg.definition, endLine: msg.endLine } })
return
case 'displayNames':
// 查到的是页面上这个版本的才换上,迟到的旧版本的不理
@@ -75,6 +75,11 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent.owner_id">owner 字段</a></p>\n')
})
it('带几行的仓库链接也能点,注释和问 AI 的回答里一样', () => {
expect(renderMarkdown('[service.py:357-364](backend/app/service.py#L357-L364)', 'answer')).toBe('<p><a href="backend/app/service.py#L357-L364">service.py:357-364</a></p>\n')
expect(renderMarkdown('[第 3 行](backend/app/service.py#L3)')).toBe('<p><a href="backend/app/service.py#L3">第 3 行</a></p>\n')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
@@ -148,3 +153,17 @@ describe('mermaid 代码块', () => {
expect(renderMarkdownOnce(`\`\`\`mermaid\n${source}\`\`\``)).toBe(codeBlock)
})
})
describe('问 AI 的回答里写成行内代码的代码位置', () => {
it('整段是代码位置的行内代码包成仓库链接,逐字长着的回答也一样', () => {
expect(renderMarkdown('见 `backend/app/routes.py:96-135`。', 'answer')).toBe('<p>见 <a href="backend/app/routes.py#L96-L135"><code>backend/app/routes.py:96-135</code></a>。</p>\n')
expect(renderMarkdown('`web/src/a.ts`', 'answer')).toBe('<p><a href="web/src/a.ts"><code>web/src/a.ts</code></a></p>\n')
expect(renderMarkdownOnce('见 `backend/app/routes.py:96`')).toBe('<p>见 <a href="backend/app/routes.py#L96"><code>backend/app/routes.py:96</code></a></p>\n')
})
it('不像代码位置的、已经在链接里的不包;注释里的不包', () => {
expect(renderMarkdown('`12:30` 和 `a:b`、`token`', 'answer')).toBe('<p><code>12:30</code> 和 <code>a:b</code>、<code>token</code></p>\n')
expect(renderMarkdown('[`backend/app/routes.py:96`](backend/app/routes.py#L96)', 'answer')).toBe('<p><a href="backend/app/routes.py#L96"><code>backend/app/routes.py:96</code></a></p>\n')
expect(renderMarkdown('见 `backend/app/routes.py:96-135`')).toBe('<p>见 <code>backend/app/routes.py:96-135</code></p>\n')
})
})
+34 -6
View File
@@ -1,24 +1,26 @@
import type { StateInline } from 'markdown-it'
import MarkdownIt from 'markdown-it'
import { isRepoImageFile, repoImagePlaceholder } from './repoImage'
import { parseRepoLink, symbolHref } from './repoLink'
import { codeLocationHref, parseRepoLink, symbolHref } from './repoLink'
/**
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员);
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名、#类名.成员 或 #L起-L止);
* 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。
* 图片只认仓库里的图片文件:先显示路径,问到文件在了再按原图比例显示。http / https 图片不显示。
* 注释(不是问 AI 的回答)里 KDoc 写法的不带路径的引用 `[Name]`、`[Class.member]` 也是链接,见 symbolReference。
* 注释(不是问 AI 的回答)里 KDoc 写法的不带路径的引用 `[Name]`、`[Class.member]` 也是链接,见 symbolReference;
* 问 AI 的回答里整段写着代码位置的行内代码也是仓库链接,见 code_location_link。
*/
const md = new MarkdownIt({ html: false, linkify: true })
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
/**
* 渲染时带的环境:symbolLinks 为真时认不带路径的引用(注释的 Markdown 才认,问 AI 的回答不认);
* codeLinks 为真时整段写着代码位置的行内代码当仓库链接(问 AI 的回答才认);
* diagrams 为真时 ```mermaid 代码块出图的占位(源码完整、不夹增删记号的才出)。
* markdown-it 自己用 env.references 记参考式链接的定义,不能占这个名字。
*/
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean, diagrams?: boolean }
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean, codeLinks?: boolean, diagrams?: boolean }
/** 方括号里的标识符(字母、数字、下划线),点分开的几段:`Name`、`Class.member`。 */
const REFERENCE = /^\[([A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*)\]/i
@@ -59,7 +61,7 @@ md.renderer.rules.image = (tokens, idx) => {
const src = typeof rawSrc === 'string' ? rawSrc : ''
const alt = token?.content ?? ''
const link = parseRepoLink(src)
if (link !== undefined && link.symbol === undefined && isRepoImageFile(link.path))
if (link !== undefined && link.symbol === undefined && link.lines === undefined && isRepoImageFile(link.path))
return repoImagePlaceholder(link.path, alt)
const esc = md.utils.escapeHtml
return `!<a href="${esc(src)}">${esc(alt)}</a>`
@@ -93,6 +95,32 @@ md.core.ruler.push('link_title_tooltip', (state) => {
}
})
/**
* 问 AI 的回答里整段写着一处代码位置的行内代码(`backend/app/routes.py:96-135`,见 [codeLocationHref])包进仓库链接,
* 和写成链接的一样能点、问不到文件时标坏;以前存下的、没写成链接的回答也能点。本来就在链接里的不再包。
*/
md.core.ruler.push('code_location_link', (state) => {
if (!(state.env as RenderEnv | undefined)?.codeLinks)
return
for (const block of state.tokens) {
if (!block.children)
continue
let depth = 0
block.children = block.children.flatMap((token) => {
if (token.type === 'link_open')
depth += 1
else if (token.type === 'link_close')
depth -= 1
const href = token.type === 'code_inline' && depth === 0 ? codeLocationHref(token.content) : undefined
if (href === undefined)
return [token]
const open = new state.Token('link_open', 'a', 1)
open.attrSet('href', md.normalizeLink(href))
return [open, token, new state.Token('link_close', 'a', -1)]
})
}
})
/** 最近渲染过的文字 → HTML,最多留 CACHE_SIZE 段,最久没用的先丢。 */
const CACHE_SIZE = 500
const cache = new Map<string, string>()
@@ -104,7 +132,7 @@ const cache = new Map<string, string>()
export type MarkdownKind = 'comment' | 'peek' | 'answer'
function envOf(kind: MarkdownKind, diagrams: boolean): RenderEnv {
return { symbolLinks: kind === 'comment', diagrams }
return { symbolLinks: kind === 'comment', codeLinks: kind === 'answer', diagrams }
}
/**
@@ -294,3 +294,38 @@ describe('跳过去以后页面还在长高,目标跟着不动', () => {
expect(card().getBoundingClientRect().top).toBe(-20)
})
})
describe('跳到几行(问 AI 的回答里点了带行号的链接)', () => {
/** 记下闪过的元素:闪完的计时器很快就摘掉 data-flash */
function watchFlashes(): Element[] {
const flashed: Element[] = []
const setAttribute = Element.prototype.setAttribute
vi.spyOn(Element.prototype, 'setAttribute').mockImplementation(function (this: Element, name: string, value: string) {
if (name === 'data-flash')
flashed.push(this)
setAttribute.call(this, name, value)
})
return flashed
}
it('开着的预览跳到函数体里的一行:展开折叠着的类和函数,把包住这一行的块滚进视口、闪整块', async () => {
await load()
const { reveal: _, ...plain } = await documentRevealing(SOURCE, 'create_for_user') as Extract<ExtensionToWebview, { type: 'document' }>
await deliver(plain)
await settle()
expect(window.scrollY).toBe(0)
const flashed = watchFlashes()
const line = SOURCE.split('\n').findIndex(text => text.includes('return self.create(user)'))
await deliver({ type: 'reveal', line, endLine: line })
await settle()
const block = flashed.at(-1)
expect(block?.matches('.cr-block')).toBe(true)
expect(block?.textContent).toContain('create')
expect(window.scrollY).toBeGreaterThan(0)
const { top } = block!.getBoundingClientRect()
expect(top).toBeGreaterThanOrEqual(0)
expect(top).toBeLessThan(window.innerHeight)
})
})
@@ -3,7 +3,7 @@
import type { CommentLink } from './repoLink'
import type { WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { commentLinkOf, parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink'
import { codeLocationHref, commentLinkOf, parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink'
/** 页面上一个链接地址是哪种能点的链接;测试里写的都得是 */
function link(href: string): CommentLink {
@@ -35,18 +35,30 @@ describe('parseRepoLink', () => {
expect(parseRepoLink('pkg/mod.py#__init__')).toEqual({ path: 'pkg/mod.py', symbol: '__init__' })
})
it('行号不算', () => {
it('不带 L 的行号、三段的符号不算', () => {
expect(parseRepoLink('file.py#123')).toBeUndefined()
expect(parseRepoLink('file.py#0')).toBeUndefined()
expect(parseRepoLink('file.py#Agent.owner.extra')).toBeUndefined()
})
it('带几行:#L起 或 #L起-L止(1 起,止可以不写 L),和符号二选一', () => {
const path = 'backend/src/luffy_agent/deployment/app/service.py'
expect(parseRepoLink(`${path}#L357`)).toEqual({ path, lines: { start: 357, end: 357 } })
expect(parseRepoLink(`${path}#L357-L364`)).toEqual({ path, lines: { start: 357, end: 364 } })
expect(parseRepoLink(`${path}#L357-364`)).toEqual({ path, lines: { start: 357, end: 364 } })
expect(parseRepoLink('file.py#L0')).toBeUndefined()
expect(parseRepoLink('file.py#L9-L3')).toBeUndefined()
expect(parseRepoLink('file.py#L3-')).toBeUndefined()
expect(parseRepoLink('file.py#L3-L4-L5')).toBeUndefined()
})
it('.. 不算', () => {
expect(parseRepoLink('../etc/passwd')).toBeUndefined()
expect(parseRepoLink('foo/../../bar')).toBeUndefined()
expect(parseRepoLink('foo/%2e%2e/bar')).toBeUndefined()
expect(parseRepoLink('./x.md')).toBeUndefined()
expect(parseRepoLink('/etc/passwd')).toBeUndefined()
expect(parseRepoLink('~/.zshrc')).toBeUndefined()
expect(parseRepoLink('C:/foo')).toBeUndefined()
})
@@ -89,6 +101,18 @@ describe('repoLinks', () => {
expect(links.isBroken(link('backend/a.py#Agent'))).toBe(false)
})
it('带几行的链接:点了发 openRepoLink 带上这几行;文件在不在和别的仓库路径一起问,文件没有就标坏', () => {
const { links, sent } = setup()
expect(links.click(link('backend/app/service.py#L357-L364'))).toBe(true)
expect(sent).toEqual([{ type: 'openRepoLink', path: 'backend/app/service.py', lines: { start: 357, end: 364 } }])
links.scan([link('backend/app/service.py#L357-L364'), link('backend/app/service.py#L3'), link('web/a.ts')])
expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/app/service.py', 'web/a.ts'], references: [] })
links.checked(1, ['backend/app/service.py'], [])
expect(links.isBroken(link('backend/app/service.py#L3'))).toBe(true)
expect(links.isBroken(link('web/a.ts'))).toBe(false)
})
it('符号没有才标坏;打开成功就不再标', () => {
const { links } = setup()
links.broken({ path: 'backend/a.py', symbol: 'Agent' })
@@ -189,3 +213,18 @@ describe('页面上的链接写在哪块的注释里', () => {
expect(at('web')).toBeUndefined()
})
})
describe('行内代码里写的代码位置', () => {
it('整段是「路径:行」「路径:起-止」或一个文件路径:换成仓库链接的地址', () => {
expect(codeLocationHref('backend/src/luffy_agent/deployment/app/routes.py:96-135')).toBe('backend/src/luffy_agent/deployment/app/routes.py#L96-L135')
expect(codeLocationHref('web/src/a.ts:12')).toBe('web/src/a.ts#L12')
expect(codeLocationHref('web/src/a.test.ts')).toBe('web/src/a.test.ts')
expect(codeLocationHref('app/(auth)/[id]/page.tsx:3')).toBe('app/(auth)/[id]/page.tsx#L3')
expect(parseRepoLink(codeLocationHref('backend/app/routes.py:96-135')!)).toEqual({ path: 'backend/app/routes.py', lines: { start: 96, end: 135 } })
})
it('不像文件位置的不算:没有 /、最后一段没有后缀、带协议、绝对路径、跑出仓库、行号不对、有空格', () => {
for (const code of ['a:b', '12:30', 'service.py:12', 'routes.py', 'src/routes', 'application/json', 'http://example.com/a.py:12', 'https://x.io/a.ts', '/abs/x.py:3', '~/x/y.py:3', './src/a.ts:3', '../a/b.ts', 'src/a.ts:0', 'src/a.ts:9-3', 'src/a.ts:12-', 'src/my file.ts', 'a/b.py#L3', 'x = a/b.py'])
expect(codeLocationHref(code), code).toBeUndefined()
})
})
+43 -10
View File
@@ -1,11 +1,13 @@
import type { SymbolReference, WebviewToExtension } from './types'
import type { LineRange, SymbolReference, WebviewToExtension } from './types'
/** 注释里指向仓库代码的链接:从仓库根起的路径,可选一个符号。 */
/** 注释、问 AI 的回答里指向仓库代码的链接:从仓库根起的路径,可选一个符号或几行。 */
export interface RepoLink {
/** 从仓库根算起,用 / 分隔,开头没有 / 或 ./ */
path: string
/** 类名、函数名,或「类名.成员名」。指向目录或整个文件时没有 */
/** 类名、函数名,或「类名.成员名」。指向目录、整个文件或几行时没有 */
symbol?: string
/** 文件里的几行(`#L357`、`#L357-L364`)。和 `symbol` 不会都有 */
lines?: LineRange
}
/**
@@ -38,9 +40,14 @@ export function parseCommentLink(href: string): CommentLink | undefined {
return REFERENCE.test(symbol) ? { symbol } : undefined
}
/** `#` 后面写的几行:`L357`、`L357-L364`,后一个 L 可以不写。 */
const LINES = /^L(\d+)(?:-L?(\d+))?$/
/** 从用户目录起的绝对路径:`~`、`~/…` */
const HOME = /^~(?:\/|$)/
/**
* 从链接地址拆出仓库路径和可选符号。
* 不是这种写法就没有:http / mailto、行号(`#123`)、带 `..`、绝对路径、盘符、以 `/` 或 `./` 开头。
* 从链接地址拆出仓库路径,和可选的符号(`#类名.成员`)或几行(`#L起-L止`,见 [LINES])。
* 不是这种写法就没有:http / mailto、不带 L 的行号(`#123`)、从 0 起或止在起前面的行号、带 `..`、绝对路径(含 `~/`)、盘符、以 `/` 或 `./` 开头。
*/
export function parseRepoLink(url: string): RepoLink | undefined {
let text: string
@@ -50,19 +57,25 @@ export function parseRepoLink(url: string): RepoLink | undefined {
catch {
return undefined
}
if (text === '' || SCHEME.test(text) || text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || text.includes('\\') || hasControl(text))
if (text === '' || SCHEME.test(text) || text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || HOME.test(text) || text.includes('\\') || hasControl(text))
return undefined
const hash = text.indexOf('#')
const rawPath = hash === -1 ? text : text.slice(0, hash)
const symbol = hash === -1 ? undefined : text.slice(hash + 1)
const fragment = hash === -1 ? undefined : text.slice(hash + 1)
const path = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath
if (!isRepoPath(path))
return undefined
if (symbol === undefined)
if (fragment === undefined)
return { path }
if (!isSymbol(symbol))
const lines = LINES.exec(fragment)
if (lines) {
const start = Number(lines[1])
const end = lines[2] === undefined ? start : Number(lines[2])
return start >= 1 && end >= start ? { path, lines: { start, end } } : undefined
}
if (!isSymbol(fragment))
return undefined
return { path, symbol }
return { path, symbol: fragment }
}
function hasControl(text: string): boolean {
@@ -84,6 +97,26 @@ function isSymbol(symbol: string): boolean {
return (parts.length === 1 || parts.length === 2) && parts.every(part => IDENT.test(part))
}
/** 行内代码里的「路径」「路径:行」「路径:起-止」:路径里没有空白、冒号、`#`、`?`、反斜杠。 */
const CODE_LOCATION = /^([^\s:#?\\]+)(?::(\d+)(?:-(\d+))?)?$/
/** 路径最后一段像文件名:点前面有字,后面是字母开头的后缀。 */
const FILE_NAME = /[^/]\.[a-z][a-z0-9]*$/i
/**
* 行内代码整段写的是一处代码位置(`backend/app/routes.py:96-135`)时,把它当仓库链接的地址:`路径:行` 是 `路径#L行`,`路径:起-止` 是 `路径#L起-L止`,只有路径就是路径本身。
* 路径得带 `/`、最后一段像文件名,免得把 `a:b`、`12:30`、`application/json` 认成位置;地址还得 [parseRepoLink] 认(不认绝对路径、跑出仓库的、不对的行号)。不是这样的没有。
*/
export function codeLocationHref(code: string): string | undefined {
const match = CODE_LOCATION.exec(code)
if (!match)
return undefined
const [, path, start, end] = match
if (!path.includes('/') || !FILE_NAME.test(path))
return undefined
const href = start === undefined ? path : `${path}#L${start}${end === undefined ? '' : `-L${end}`}`
return parseRepoLink(href) === undefined ? undefined : href
}
/**
* 页面上一个链接元素是哪种能点的链接(同 parseCommentLink)。不带路径的引用再带上它写在哪块的注释里:离它最近的那张块卡片(.cr-block)
* 上记着的块记号(data-anchor);不在块卡片里的、那张卡片没记块记号的(删掉的块)不带,扩展端只从文件顶层找。
@@ -14,7 +14,7 @@ import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
import { placementOf } from './placement'
import { cardTexts, occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { cardTexts, linesPlan, occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { placed } from './testBlocks'
import { collapsedIds, propertyItemId, propertyRowId } from './viewState'
@@ -328,6 +328,38 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
})
})
describe('跳到几行:滚到哪块、展开哪些', () => {
/**
* 1 函数(到第 8 行)
* 3 如果(到第 4 行)
* 4 返回
* 10 函数(到第 12 行)
*/
const ret = textBlock('0.0.0', 'return', 4, [word('返回')])
const branch: WrittenBlock = { ...textBlock('0.0', 'if', 3, [word('如果')], [ret]), lines: [3, 4] }
const blocks: WrittenBlock[] = [
{ ...textBlock('0', 'function', 1, [word('函数')], [branch]), lines: [1, 8] },
{ ...textBlock('1', 'function', 10, [word('函数')]), lines: [10, 12] },
]
function linesIn(written: WrittenBlock[], start: number, end: number): RevealPlan | undefined {
const page = placed(written)
return linesPlan(page, placementOf(page), start, end)
}
it('取把这几行整段包住的最小那块,展开它的祖先块', () => {
expect(linesIn(blocks, 3, 4)).toEqual({ id: '0.0', expand: ['0'] })
expect(linesIn(blocks, 4, 4)).toEqual({ id: '0.0.0', expand: ['0', '0.0'] })
expect(linesIn(blocks, 2, 6)).toEqual({ id: '0', expand: [] })
expect(linesIn(blocks, 10, 11)).toEqual({ id: '1', expand: [] })
})
it('没有一块包得住整段(跨了两块)时取包住第一行的块;第一行不在任何块里取第一块;没有块时没有目标', () => {
expect(linesIn(blocks, 4, 11)).toEqual({ id: '0.0.0', expand: ['0', '0.0'] })
expect(linesIn(blocks, 0, 0)).toEqual({ id: '0', expand: [] })
expect(linesIn([], 1, 2)).toBeUndefined()
})
})
describe('跳到定义:闪哪一处(defines)', () => {
const range = (startIndex: number, endIndex: number): { startIndex: number, endIndex: number } => ({ startIndex, endIndex })
/**
+25 -13
View File
@@ -167,14 +167,11 @@ function closer(spot: Spot, best: Spot): boolean {
* 父块的大范围盖住后面那个函数的名字时,停在函数上,不闪父块。
* 一样短时,讲这个定义的块胜过只是显示着这个名字的块,再一样就取先遇到的——
* 路由器卡片的接口表里显示着的函数名和下面接口卡片自己的名字一样长,停在接口卡片上,不闪接口表里那一格。
* 页面上找不到这处定义,就取包住 line 的块(块在文件里的首尾行之间);几块都包住时取行数最少的,一样少时取先遇到的外层。
* 没有任何块包住这一行(它在所有块之前,或落在块与块的空档)时取第一块。没有块时没有目标。
* 页面上找不到这处定义,就按行找包住 line 的块(见 [linesPlan])。
* 目标的祖先块都要展开。line、definition 是文件里的,各块里的位置按 placement 换算。
*/
export function revealPlan(blocks: BlockView[], placement: Placement, line: number, definition: number): RevealPlan | undefined {
let best: { plan: RevealPlan, spot: Spot } | undefined
let byLine: Placed | undefined
let byLineSpan = Number.POSITIVE_INFINITY
for (const item of placedBlocks(blocks)) {
const place = placement.of(item.block.id)
if (!place)
@@ -182,17 +179,32 @@ export function revealPlan(blocks: BlockView[], placement: Placement, line: numb
const spot = locate(item.block, definition - place.offset)
if (spot && (best === undefined || closer(spot, best.spot)))
best = { spot, plan: { id: item.block.id, ...(spot.row !== undefined && { row: spot.row }), expand: [...item.ancestors, ...spot.expand] } }
if (place.line <= line && line <= place.endLine) {
const span = place.endLine - place.line
if (span < byLineSpan) {
byLine = item
byLineSpan = span
}
return best ? best.plan : linesPlan(blocks, placement, line, line)
}
/** 把 `start` 到 `end` 行整段包住的块里行数最少的那块;一样少时取先遇到的外层。没有块包得住时没有。 */
function smallestCovering(blocks: BlockView[], placement: Placement, start: number, end: number): Placed | undefined {
let found: Placed | undefined
let span = Number.POSITIVE_INFINITY
for (const item of placedBlocks(blocks)) {
const place = placement.of(item.block.id)
if (place && place.line <= start && end <= place.endLine && place.endLine - place.line < span) {
found = item
span = place.endLine - place.line
}
}
}
if (best)
return best.plan
const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
return found
}
/**
* 跳到几行(`start` 到 `end`,文件里的行,0 起):取把这几行整段包住的块里行数最少的那块(一样少时取先遇到的外层),展开它的祖先块。
* 没有一块包得住整段(跨了两块)时只看第一行;第一行也不在任何块里(在所有块之前,或落在块与块的空档)时取第一块。没有块时没有目标。
*/
export function linesPlan(blocks: BlockView[], placement: Placement, start: number, end: number): RevealPlan | undefined {
const target = smallestCovering(blocks, placement, start, end)
?? smallestCovering(blocks, placement, start, start)
?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
return target && { id: target.block.id, expand: target.ancestors }
}
+12 -3
View File
@@ -225,11 +225,19 @@ export type Excluded = Record<string, string[]>
/**
* 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
* 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下
* 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下。
* endLine 只在跳到几行(line 到 endLine)时有:展开、闪包住这几行的那块。都没有是从文本编辑器切过来,只滚不闪
*/
export interface Reveal {
line: number
definition?: number
endLine?: number
}
/** 文件里的几行:从 start 行到 end 行,1 起、两头都算,同链接里的 `#L起-L止` */
export interface LineRange {
start: number
end: number
}
/**
@@ -627,7 +635,8 @@ export type ExtensionToWebview
* selectProfile:kind 那种对话换 profile;不在本地先改,等扩展端发回这种的 profiles 再按它显示
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
* findNavigation:眼下查找栏的快捷键(下一处 / 上一处)归不归这个页面(查找栏开着、页面有焦点),变了才发
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,lines 是文件里的几行(和 symbol 二选一),
* 都没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference),
* 从那块的声明起一层层往外找
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问
@@ -657,7 +666,7 @@ export type WebviewToExtension
| { type: 'selectProfile', kind: ChatKind, id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'findNavigation', available: boolean }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string, lines?: LineRange }
| { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
| { type: 'openCallTree', version: number, anchor: string }
@@ -3,7 +3,7 @@ import { useLayoutEffect } from 'react'
import { blockPath } from './filter'
import { pageLayout } from './pageLayout'
import { placementOf } from './placement'
import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
import { linesPlan, revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
import { scrollOwner } from './scrollOwner'
import { stickyHeight } from './stickyScroll'
@@ -53,11 +53,12 @@ function nameAt(block: HTMLElement, offset: number): HTMLElement | null {
/**
* 滚到 reveal 指的位置,处理完调 done:
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
* ① 从文本编辑器切过来(没有 definition,也没有 endLine):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
* ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
* 每一步之后重新渲染、再进来一次;最后把目标滚进筛选栏下面并闪几下。
* 属性行 / 子行是这处定义时闪那一行;这块里显示着这个名字(下标盖住定义处)就闪这个名字,不闪整张卡片;
* 名字不在页面上才闪整块。目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。
* ③ 跳到几行(有 endLine,点了带 `#L起-L止` 的链接):目标是包住这几行的块(见 [linesPlan]),露出、展开、滚过去同 ②,闪整块。
* 滚过去以后由 [scrollOwner] 跟住目标(① 跟住那块),页面再长高(注释里的图画出来、中文名到了)也停在原处。
*/
export function useReveal(
@@ -73,8 +74,8 @@ export function useReveal(
useLayoutEffect(() => {
if (!doc || !reveal)
return
const { line, definition } = reveal
if (definition === undefined) {
const { line, definition, endLine } = reveal
if (definition === undefined && endLine === undefined) {
const card = pageLayout.cardForLine(line)
if (card)
scrollOwner.align({ line: card.line, offset: 0, scrollTop: card.top })
@@ -82,7 +83,7 @@ export function useReveal(
return
}
const placement = placementOf(doc.blocks)
const found = revealPlan(doc.blocks, placement, line, definition)
const found = definition === undefined ? linesPlan(doc.blocks, placement, line, endLine ?? line) : revealPlan(doc.blocks, placement, line, definition)
const plan = found && uncoveredPlan(found, covered)
const path = plan ? blockPath(plan.id) : []
if (path.some(id => filtered.has(id))) {
@@ -99,7 +100,7 @@ export function useReveal(
const locate = (): HTMLElement | null => {
const block = plan ? document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`)
const name = row || !block || base === undefined ? null : nameAt(block, definition - base)
const name = row || !block || base === undefined || definition === undefined ? null : nameAt(block, definition - base)
return row ?? name ?? block
}
const el = locate()