✨ feat(vscode): 代码阅读预览问 AI 的回答里代码位置写成链接、点了能跳:提示词要求写 [名字](路径#符号) 或 [文件名:起-止](路径#L起-L止) 并给出仓库根,仓库链接认 #L 行号(注释里同一套),旧回答里整段是「路径:行」的行内代码也当链接,带行号的在预览里展开、闪包住那几行的块,不支持预览的用文本编辑器选中
This commit is contained in:
1 parent
ceeaad91a7
commit
e401e4aec3
22 files changed
+516
-90
No files matched your search
@@ -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('文件的语言', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in new issue
Block a user